Architecture

06. Browser navigation

How navigation loads a destination and updates the page and browser history.

React commit, browser history commit, and Flight completion are separate events. A route can become visible while deferred data is still streaming, so navigation must track both the visible tree and its unfinished resources.

Hydration starts from Flight embedded in HTML. Later responses carry the same server-generated route-tree model, but update the existing React root rather than creating a document.

Establish the first tree and choose client navigation

activateBrowser in client/application.ts loads the initial Flight payload and calls ReactDOMRenderer.hydrate. Hydration passes formState to React and waits for a layout effect to initialize BrowserRenderer with the tree and state setter. Only then are refresh, the Server Function callback, and any supported client router installed.

client/browser-capabilities.ts selects client routing only when both window.navigation and window.NavigationPrecommitController exist. Otherwise, the hydrated page keeps document navigation. Even with both APIs, some events stay with the browser:

Excerpt from packages/core/src/client/navigation-routing.ts
const ReactTransitionNavigationInfo = "react-transition";

export const NativeDocumentNavigationInfo = "effront-native-document";

export const isRoutedNavigation = (event: NavigateEvent) =>
  event.canIntercept &&
  !event.hashChange &&
  event.downloadRequest === null &&
  event.formData === null &&
  event.info !== ReactTransitionNavigationInfo &&
  event.info !== NativeDocumentNavigationInfo &&
  event.navigationType !== "reload";

Interception excludes hash-only changes, downloads, forms, reloads, and the two marked navigation types. The document marker prevents Effront's fallback loads from being intercepted again.

browserMain uses Effect.scoped and Effect.never to retain services and subscriptions. Initial Flight and hydration-start failures show the browser failure screen. React rendering errors belong to the renderer's Error Boundary.

Load a route or return control to the browser

client/route-loader.ts reuses cached trees for history traversals by destination entry ID. Other navigations and cache misses call FlightClient.load with a GET and Accept: text/x-component. Each response has its own Scope, which can outlive the initial payload decode.

  • Document fallback: a non-2xx or non-Flight response becomes a Document result. The router releases it and starts a document load at the requested destination.
  • Load error: transport failure, a missing or invalid resolved URL, and decode failure produce FlightLoadError with RequestFailed, UnexpectedResponse, and DecodeFailed, respectively.
  • Flight: the result includes the payload, completed, release, and resolvedUrl. A decoded route tree does not mean the stream has completed.

Before publication, client/client-router.ts checks the resolved destination. A different origin requires document navigation. A changed same-origin URL requires document replacement for traversal or when no precommit controller is available. Otherwise, that controller can redirect after React commits. The requested hash survives only if the resolved URL lacks a hash and has the same origin, path, and query.

Distinguish scheduling a render from committing it

An asynchronous Transition Action loads the route through BrowserEffectRunner. After loading, an inner transition publishes the tree, but publication is not commit:

Excerpt from packages/core/src/client/client-router.ts
          const rendererNavigation = yield* Effect.sync(() => {
            let navigation!: BrowserRendererNavigation;
            startTransition(() => {
              const fromIndex =
                navigationApi.getTransition()?.from.index ??
                navigationApi.getCurrentEntry()?.index ??
                null;
              for (const type of getNavigationTransitionTypes(event, fromIndex)) {
                addTransitionType(type);
              }
              for (const type of linkTransitionTypes) {
                addTransitionType(type);
              }
              navigation = browserRenderer.navigate(command.resource.routeTree);
            });
            return navigation;
          });

browserRenderer.navigate schedules a state update and returns three lifecycle handles:

  • committed resolves when a layout effect in client/react-dom-renderer.tsx calls browserRenderer.commit(render).
  • retired resolves when a replacement commit no longer retains the tree.
  • discard requests restoration of the current tree for a pending render and waits for retirement.

For cancelable events, event.intercept uses a precommitHandler. It waits for React commit, applies any eligible redirect, and registers an addHandler callback to record history commit. Non-cancelable traversals use the ordinary handler, which cannot defer history this way. Preparation failure on that path reloads the document.

Transition types describe navigation kind and direction when known. Link-driven push and replace can add data-effront-transition-types values after duplicate and reserved-type filtering. These labels do not change commit or lifetime rules. Application usage belongs to client navigation and page transitions.

A pending candidate moves through Loading → Publishing → Rendering, separately from the visible navigation. Its generation symbol and AbortController let a newer navigation cancel pending work without immediately releasing the visible tree.

An obsolete load is released rather than published. An already-scheduled render is discarded, then released after retirement. BrowserRenderer retains trees that are visible or referenced by pending publications, including restoration requests. Aborting navigation is therefore not equivalent to retiring a render. Unpublished or retired commits and invalid lifecycle transitions throw TypeError.

A newly loaded visible route is cached only after both history commit and successful Flight completion. NavigationEntryState and NavigationFlightState track these independently:

  • History first: wait for Flight, then cache and release.
  • Flight first: release the response and retain the cache callback until the history entry is known.
  • Stream failure: release without caching.

Render retirement also releases its resource, even after a newer generation starts. The cache holds trees, not open response streams. RouteLoader evicts disposed history entries and replaces the cache Map on refresh, preventing delayed callbacks from repopulating the new cache. Server Function responses use the same renderer lifecycle to update the current page.