Skip to content

Static import most popular routes to improve user experience - #1017

Draft
cigamit wants to merge 1 commit into
mainfrom
user_experience
Draft

cigamit wants to merge 1 commit into
mainfrom
user_experience

Conversation

@cigamit

@cigamit cigamit commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

In a previous PR, we modified all routes to use React.lazy() which helped reduce the rather large initial download size. This is a good thing, but (at least in Devel) it is affecting the user experience as every 1st click to a page now takes ~1 additional second and makes it feel sluggish. I believe this to only be affecting Dev, as a production build resolves each lazy route to one prebuilt chunk with module preload hints, so the delay there is a single small round trip. So I am leaving this as a draft until I can verify in a production environment (awaiting operator updates).

All this PR does is take the main 5 pages and adds them back as static imports, so those pages will always load faster.
It added ~200kb to the entry chunk.

                  | Before    | After
Entry chunk       | 108 KB    | 316 KB
JS chunks         | 118       | 69
Total JS on disk  | 4.34 MB   | 4.30 MB

@cigamit cigamit self-assigned this Sep 16, 2026
Copilot AI lite review requested due to automatic review settings September 16, 2026 04:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The change affects bundle size and route-loading behavior and requires human verification of production performance.

Pull request overview

Updates route loading to eagerly load five frequently used screens, improving first-navigation responsiveness while retaining lazy loading for other routes.

Changes:

  • Static-imports Credentials, Hosts, Inventory, Projects, and Templates.
  • Keeps less-used routes lazy-loaded.
File summaries
File Description
ascender/ui/src/routeConfig.tsx Adjusts route screen imports between static and lazy loading.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@blaipr

blaipr commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Sorry about the sluggishness, that lazy split in #939 was mine.

I built all three locally on node 24 so this is measured rather than guessed. Your numbers reproduce exactly:

main (lazy) this PR your table
entry chunk (gzip) 108 KB 316 KB 108 → 316 ✅
JS chunks 118 69 118 → 69 ✅
total JS on disk 4.33 MB 4.30 MB 4.34 → 4.30 ✅

One correction on the mechanism

The built index.html on main has one modulepreload link, and it is for ContentError, not a route:

<script type="module" crossorigin src="/static/js/index.je_MZB9K.js">
<link rel="modulepreload" crossorigin href="/static/js/ContentError.XQ--JhbY.js">

So production is not preloading the lazy routes today. Vite's __vitePreload only fetches a chunk's dependencies at the moment the dynamic import runs, which is the click itself.

The preload hints you are describing actually appear because of this PR: the count goes from 1 to 24, since the five screens become static imports of the entry.

Your conclusion still holds, just for a different reason. Dev is slow because Vite transforms modules on demand with no bundling. Production is one round trip for a prebuilt chunk, and those chunks are small:

screen gzip
Host 4.7 KB
Project 10 KB
Credential 12 KB
Inventory 35 KB
Template 38 KB

So production will still cost a fetch on first click, but tens of milliseconds rather than a second.

A cheaper way to get the same result

Warm those five during idle once the authenticated shell has painted, instead of putting them in the entry:

const POPULAR_SCREENS = [
  () => import('screens/Template'),
  () => import('screens/Inventory'),
  () => import('screens/Host'),
  () => import('screens/Project'),
  () => import('screens/Credential'),
];

Called from RenderAppContainer in a useEffect, behind requestIdleCallback.

import() populates the module registry, so when the route renders, React.lazy resolves from it with no fetch and no Suspense fallback. Same user-visible result as a static import.

I built it:

main this PR idle prefetch
entry chunk (gzip) 108 KB 316 KB 108 KB
JS chunks 118 69 118
entry cost vs main +208 KB +180 bytes
first click, those 5 one fetch none none

The 180 bytes is the prefetch function itself. Splitting is preserved, and the five screens stay their own chunks.

It also fixes the thing you actually hit: in dev the transform happens during idle rather than on the click, so Devel stops feeling sluggish too.

Worth adding:

  • Fire it only after the authenticated shell mounts, so nothing is warmed on the login page.
  • requestIdleCallback with a timeout, so a busy machine still gets it.
  • Skip on saveData or a 2g effectiveType, so nobody's metered connection pays for it.
  • Hover or focus prefetch on the nav links would cover the other 21 screens at no speculative cost.

The honest trade-off: prefetch spends bandwidth on screens a session may never open, roughly 100 KB gzipped for the five, at idle and after paint. That is the one axis where a static import is not worse, since it spends the same bytes anyway, just earlier and for everyone.

What I measured is build cost: entry size, chunk count, clean typecheck, green vite build. I have not profiled click latency in a browser, so the no-fetch claim rests on module registry semantics rather than a trace.

Sent it as #1032 so you can compare the two side by side.

blaipr added a commit to blaipr/ascender that referenced this pull request Sep 17, 2026
ctrliq#939 split every screen out of the entry bundle, which is why the first click
to a page now waits on a fetch. ctrliq#1017 answers that by putting the five most
used screens back into the entry, at 108 KB to 316 KB gzipped.

The registry does the same job for less. import() during idle populates the
module registry, so when the route renders React.lazy resolves from it with no
fetch and no Suspense fallback. Same user visible result as a static import,
without those screens entering the bundle everyone downloads before the login
form. Measured: the entry grows 197 bytes rather than 208 KB, and all 118
chunks stay split.

It also helps the environment the slowness was reported in. Dev is slow
because Vite transforms on demand, and this moves that transform into idle
time rather than the click.

Called from RenderAppContainer, so nothing is warmed on the login page, with
a requestIdleCallback timeout so a busy tab still gets it, and skipped on
saveData or a 2g connection.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants