Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
51a971d
Add preload-then-commit client navigation core with top progress bar
cursoragent Jul 4, 2026
d84a1bb
Convert data routes to preload-then-commit loaders with frame prefetch
cursoragent Jul 4, 2026
c127578
Apply spin-delay semantics to navigation progress bar (150ms delay, 2…
cursoragent Jul 4, 2026
d3dee69
Revert generated kody-ui-utils.css formatter noise
cursoragent Jul 4, 2026
1f2671d
Address Bugbot findings: consume preloads on same-path refresh, abort…
cursoragent Jul 4, 2026
c788b75
Address reviewer feedback: latest-wins abort chain for form POSTs and…
cursoragent Jul 4, 2026
ec8d3dc
Sync router UI on popstate when loader redirects externally
cursoragent Jul 4, 2026
6f089a9
Consume preloaded oauth-authorize data on same-path refreshes
cursoragent Jul 4, 2026
25ee60b
Add intent-based route prefetching (hover/focus/touch)
cursoragent Jul 4, 2026
cb8f943
Debounce hover intent and use monotonic clock for prefetch freshness
cursoragent Jul 5, 2026
45a4a92
Format client-router
cursoragent Jul 5, 2026
cc3ce9c
Cancel pending hover-intent timer on navigation and skip current-page…
cursoragent Jul 5, 2026
a6a54ce
Skip intent prefetch for the path an active navigation is loading
cursoragent Jul 5, 2026
615b0e6
Invalidate stale intent prefetch when navigating elsewhere
cursoragent Jul 5, 2026
f28a5cd
Reset admin load-failure latch when the route href changes
cursoragent Jul 5, 2026
a0468cd
Force route refetch when a same-path refresh loader fails
cursoragent Jul 5, 2026
1be46b1
Drop optimistic href pre-set that raced async navigation
cursoragent Jul 5, 2026
e419188
Consume stale navigation marker in community detail shell
cursoragent Jul 5, 2026
d44e488
Cancel pending hover timer when focus/touch intent prefetches
cursoragent Jul 5, 2026
f5cef55
Skip route loader on hash-only popstate
cursoragent Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ submissions (`GET`/`POST`) and routes them in-place through the client router.
Normal app navigations stay in-place through the client router instead of
requiring a full document refresh.

### Preload-then-commit

SPA navigations use a **preload-then-commit** model (similar to React Router
data routers): before `history.pushState` and the route swap, the client router
runs a registered **route loader** for the destination URL, fetches JSON API
data, and stores it in a single-slot preloaded navigation store. Only after the
loader finishes (or is skipped when no loader matches) does the router commit
the URL change and notify subscribers. Route components consume that payload
synchronously on first render via `tryConsumeRouteLoaderData`, so the UI updates
once with data already present instead of swapping routes into a loading state.

Route loaders are registered in `clientRouteLoaders` (`routes/index.tsx`) and
matched with the same Remix route-pattern specificity as `clientRoutes`. Loaders
return `null` to abort the SPA navigation (for example, `401` → full-page login
redirect). Loader errors still commit the navigation so the destination route
can fall back to its own fetch. Hash-only changes commit immediately without a
loader. Back/forward (`popstate`) and same-path refreshes after form POST also
run loaders before notifying, keeping the previous UI visible until data is
ready.

A thin top-of-viewport **navigation progress bar** listens for `navigationstart`
/ `navigationend` on `routerEvents` and appears only when a navigation is still
pending after a short delay.

Full page navigations occur for:

- Explicit browser reloads/new tab loads
Expand Down
12 changes: 10 additions & 2 deletions packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { type Handle, css } from 'remix/ui'
import { clientRoutes } from './routes/index.tsx'
import { listenToRouterNavigation, Router } from './client-router.tsx'
import { clientRouteLoaders, clientRoutes } from './routes/index.tsx'
import {
listenToRouterNavigation,
registerRouteLoaders,
Router,
} from './client-router.tsx'
import { AppLoaderDataProvider } from './loader-data-context.tsx'
import { NavigationProgress } from './navigation-progress.tsx'
import { readRouterPathname, readRouterSearch } from './router-location.tsx'
import {
fetchSessionInfo,
Expand All @@ -15,6 +20,8 @@ import { userHasRole } from '#app/permissions.ts'
import { buildAuthLink } from './auth-links.ts'
import { colors, mq, spacing, typography } from './styles/tokens.ts'

registerRouteLoaders(clientRouteLoaders)

type AppProps = {
embeddedSession?: SessionInfo | null
loaderData?: AppLoaderData
Expand Down Expand Up @@ -144,6 +151,7 @@ export function App(handle: Handle<AppProps>) {

return (
<AppLoaderDataProvider loaderData={handle.props.loaderData}>
<NavigationProgress />
<main
mix={css({
maxWidth: isWideLayout ? 'none' : '52rem',
Expand Down
48 changes: 47 additions & 1 deletion packages/worker/client/client-router.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from 'vitest'
import { matchRoute } from './client-router.tsx'
import {
matchRoute,
matchRouteLoader,
type RouteLoader,
} from './client-router.tsx'

test('client route matching uses Remix route-pattern specificity', () => {
const tokenDetailRoute = 'token-detail-route' as unknown as JSX.Element
Expand Down Expand Up @@ -32,3 +36,45 @@ test('client route matching handles nested static routes over dynamic parents',
genericSecretRoute,
)
})

test('route loader matching uses the same route-pattern specificity', () => {
const accountLoader = (async () => ({
accountProfile: { ok: true },
})) as RouteLoader
const tokenLoader = (async () => ({
accountPackageInvocationTokens: { ok: true },
})) as RouteLoader
const loaders = {
'/account/package-invocation-tokens/:tokenId': tokenLoader,
'/account/package-invocation-tokens/new': tokenLoader,
'/account': accountLoader,
}

expect(matchRouteLoader('/account', loaders)).toBe(accountLoader)
expect(
matchRouteLoader('/account/package-invocation-tokens/new', loaders),
).toBe(tokenLoader)
expect(
matchRouteLoader('/account/package-invocation-tokens/token-1', loaders),
).toBe(tokenLoader)
})

test('route loader matching prefers nested static routes over dynamic parents', () => {
const genericSecretLoader = (async () => ({
accountSecrets: { ok: true },
})) as RouteLoader
const userSecretLoader = (async () => ({
accountSecrets: { ok: true },
})) as RouteLoader
const loaders = {
'/account/secrets/:secretId': genericSecretLoader,
'/account/secrets/user/:secretName': userSecretLoader,
}

expect(matchRouteLoader('/account/secrets/user/github-token', loaders)).toBe(
userSecretLoader,
)
expect(matchRouteLoader('/account/secrets/secret-1', loaders)).toBe(
genericSecretLoader,
)
})
Loading