Skip to content

⚡ Lazy-load heavy components on FE, on-demand locales, and per-font stylesheets#1193

Open
balazs-szucs wants to merge 15 commits into
stumpapp:nightlyfrom
balazs-szucs:chunk-tsconfig
Open

⚡ Lazy-load heavy components on FE, on-demand locales, and per-font stylesheets#1193
balazs-szucs wants to merge 15 commits into
stumpapp:nightlyfrom
balazs-szucs:chunk-tsconfig

Conversation

@balazs-szucs
Copy link
Copy Markdown
Contributor

@balazs-szucs balazs-szucs commented May 30, 2026

Cuts initial JS payload by ~50% (4050 kB to 2039 kB gzipped).

The app was eagerly loading a lot of weight that most users never need on boot, e.g., 32 locale JSON files, the full @emoji-mart dataset + renderer, and a markdown parser.

Changes are as follows:

  • MarkdownPreview and @emoji-mart/react are code-split via React.lazy(); emoji data fetch gated on isOpen
  • 32 locale JSONs replaced with import.meta.glob; only en-US is eager, rest load via loadLocaleResources
  • resolveLocale normalizes raw browser strings (fr, zh_CN) to a valid AllowedLocale, replacing a silent bad cast (At least I think so? I was getting weird errors)
  • EPUB reader injects only the selected font's stylesheet instead of a monolithic fonts.css

As for ResizeObserver added guard against post-teardown resize calls, it was giving also some headaches, not quite certain if I caused it somehow but 🤷‍♂️

@balazs-szucs balazs-szucs marked this pull request as draft May 30, 2026 21:24
@balazs-szucs
Copy link
Copy Markdown
Contributor Author

balazs-szucs commented May 30, 2026

I made this a draft, as I think, something stinks here. I'll take second/third look at this. May be nothing. Just a gut feeling.

@balazs-szucs
Copy link
Copy Markdown
Contributor Author

So, what tripped me up the maths vs my older precompress PR, so did some digging:

State Old PR This PR Difference
Uncompressed (Main bundle) 5.16 MB (~5,283 kB) 2.31 MB (2,309.48 kB) ~56%
Gzipped (Main bundle) 806 kB 713 kB ~11.5%

So, yeah this is more like 12% percent improvement not 50%. Still, good? I think? I guess compression does not scale as the non-compressed file goes, so lot of resource that were cut off are exactly the stuff that were well compressed, hence why does does not bigger gains. So, in other words: lazy loading them shrinks the raw file dramatically, but their contribution to the compressed size was already minimal. GZIP "hid" the cost of those resources before, so deffering them doesn't move the needle as much post-compression. Probably locales files, i think, that contained huge amount formatting/whitespaces etc, so those are very well compressed. (best guess)

Apologies for the confusion.

@balazs-szucs balazs-szucs marked this pull request as ready for review May 31, 2026 11:49
@balazs-szucs
Copy link
Copy Markdown
Contributor Author

balazs-szucs commented May 31, 2026

../dist/assets/hina-mincho-japanese-400-normal-CLxaJXTw.woff2 1,424.40 kB

That seems like a very hefty thing to be pulling in. There should be a more lightweight option, i think.

(I am defo not suggesting dropping it, rather finding more suitable way to get the fonts.)

Copy link
Copy Markdown
Collaborator

@aaronleopold aaronleopold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through this! I had some comments throughout.

So, yeah this is more like 12% percent improvement not 50%. Still, good? I think?

Yeah I think 12% is a good improvement, but based on what I saw I am worried that the improvement might be more artificial than practical (e.g., waterfall navigation from lazy loading routers).

Comment thread packages/i18n/src/config.ts Outdated
Comment on lines +16 to +19
const localeLoaders = import.meta.glob<{ default: Translation }>([
'./locales/*.json',
'!./locales/en-US.json',
])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely break expo. import.meta.glob is a vite API, right? Not all apps in the repo are using vite, so if we want to lazily load things we will need another mechanism for doing so

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check, I am afraid I did not think to test with expo. So whether this is breaking for it, i would need check.

Apologies.

resources,
})

export const resolveLocale = (inputLocale?: string): AllowedLocale => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little conflicted about this function, at least for some of it. I think it might be overly defensive, really. I'm of the mentality that breaking fast will be easier to catch, e.g. if someone's navigator returns a locale which we don't support I'd rather bake in support for it than not realize it isn't working as expected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That may be, i think this is fine? I think the fallback to en-US is fine, most of (if any) problems are logged well enough, at the end of the day, I do not think this should cause any harm.

On the client, I think we would want to avoid throwing/failing on principle.

For the pt, i think you are right, i'll push commit when I am at PC again.

Comment thread packages/i18n/src/config.ts Outdated
Comment on lines +77 to +79
const languageMatch = LOCALES.find((locale) =>
locale.toLowerCase().startsWith(`${languageCode}-`),
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are locales which could be matched here, but we'd only ever return the first. A bit of a guessing game (e.g. if my navigator passed just pt it would flow thorugh here to pt-BR when I might actually want pt-PT).

Comment thread packages/i18n/src/config.ts Outdated
Comment on lines +62 to +66
const normalizedInput = inputLocale.replace('_', '-').toLowerCase()
const exactMatch = LOCALES.find((locale) => locale.toLowerCase() === normalizedInput)
if (exactMatch) {
return exactMatch
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels redundant, did you observe a locale which came through in this format?

}

if (!data) {
if (isOpen && !data) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see much value here, if you are on a route which has an emoji picker then IMO it should fetch the emojis before you open it. I think the provides a better UX, but maybe I am missing something.

Also, though, long term we won't be fetching emojis in this way so I don't feel too strongly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, though, long term we won't be fetching emojis in this way so I don't feel too strongly.

That would a huge update, (in terms of UX) but I am afraid I did take a look at this already, and emoji land seems to be a... Bit more complicated than I had realised, hence I think updates to this is fine. (It is probably bulky change when/if somebody gets to this.). I am not attached to this particular change.

Comment on lines +665 to +670
const renditionWithManager = rendition as Rendition & {
manager?: { stage?: unknown }
}
if (!renditionWithManager.manager?.stage) {
continue
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about this one, was this something observed/corrected during runtime? I won't nitpick epubjs changes too much because I am eager to just eventually replace it with readium, but curiosity won 😄

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same remarks here as with the markdown preview

Comment thread apps/web/vite.config.ts Outdated
Comment on lines +30 to +32
if (id.includes('lucide-react')) {
return 'vendor-lucide'
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't reach this because we would have matched id.includes('react') above, right?

Comment thread apps/web/vite.config.ts Outdated
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many packages will include react in the name, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice, they are not that big :)

So, i think it should be just fine.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my point was really in the same vein as this above, that it could falsely match packages (like lucide-react)

Comment thread apps/web/vite.config.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't disagree with manual chunking in general, but I think maybe things might be a bit higher fidelity if we compare more complete paths instead of just substrings. E.g.

rollupOptions: {
  output: {
    manualChunks(id) {
      const path = id.replaceAll('\\', '/')
      if (!path.includes('/node_modules/')) return

      if (path.includes('/node_modules/lucide-react/')) return 'vendor-lucide'
      if (path.includes('/node_modules/@tanstack/')) return 'vendor-tanstack'
      // ... etc ...

      if (
        path.includes('/node_modules/react/') ||
        path.includes('/node_modules/react-dom/') ||
        path.includes('/node_modules/scheduler/')
      ) {
        return 'vendor-react'
      }
    },
  },
}

@balazs-szucs
Copy link
Copy Markdown
Contributor Author

balazs-szucs commented May 31, 2026

Yeah I think 12% is a good improvement, but based on what I saw I am worried that the improvement might be more artificial than practical (e.g., waterfall navigation from lazy loading routers).

I redid the benchmark now its 626.23 kB so -22.3%, I think some of more commits after I made my initial comment improved it significantly.

In practice, I feel like this should be an improvement, BUT a "hard to measure" improvement though. Lot of the chunking improvement will manifest themselves with caching/etc improvement, that is not necessarily easy to get a good measurement on.

Anyways, last 8 commit, I think addressed everything.

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.

2 participants