npm.io
0.15.11 • Published 1h ago

@real-router/preact

Licence
MIT
Version
0.15.11
Deps
3
Size
395 kB
Vulns
0
Weekly
490

@real-router/preact

License: MIT

Preact integration for Real-Router — hooks, components, and context providers.

Installation

npm install @real-router/preact @real-router/core

@real-router/core is the entry-point dependency the API revolves around; @real-router/route-utils and @real-router/sources are pulled in automatically as transitive deps (used internally by useRouteUtils / hook subscriptions). Add @real-router/browser-plugin (or hash-plugin / navigation-plugin / memory-plugin) when you need History API integration — the Quick Start below uses it.

Peer dependency: preact >= 10.28.0 (Preact 10) or ^11.0.0-0 (Preact 11 beta and later). The adapter imports DOM types (HTMLAttributes, TargetedMouseEvent) from the top-level preact namespace introduced in 10.28; Preact 11's JSX-namespace restructure preserves the same imports.

Quick Start

import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { RouterProvider, RouteView, Link } from "@real-router/preact";

const router = createRouter([
  { name: "home", path: "/" },
  {
    name: "users",
    path: "/users",
    children: [{ name: "profile", path: "/:id" }],
  },
]);

router.usePlugin(browserPluginFactory());
router.start();

function App() {
  return (
    <RouterProvider router={router}>
      <nav>
        <Link routeName="home">Home</Link>
        <Link routeName="users">Users</Link>
      </nav>
      <RouteView nodeName="">
        <RouteView.Match segment="home">
          <HomePage />
        </RouteView.Match>
        <RouteView.Match segment="users">
          <UsersPage />
        </RouteView.Match>
        <RouteView.NotFound>
          <NotFoundPage />
        </RouteView.NotFound>
      </RouteView>
    </RouterProvider>
  );
}

Hooks

Hook Returns Re-renders
useRouter() Router Never
useNavigator() Navigator Never (stable ref, safe to destructure)
useRoute() { navigator, route, previousRoute } Every navigation
useRouteNode(name) { navigator, route, previousRoute } Only when node activates/deactivates
useRouteUtils() RouteUtils Never
useRouterTransition() { isTransitioning, isLeaveApproved, toRoute, fromRoute } On transition start/end
useRouteExit(handler, options?) void — wraps router.subscribeLeave with abort + same-route guards Never (stable subscription)
useRouteEnter(handler, options?) void — fires on nav-driven mount via useRoute() snapshot Every navigation (host component reads useRoute()); handler ref + subscription are stable across renders
// useRouteNode — re-renders only when "users.*" changes
function UsersLayout() {
  const { route } = useRouteNode("users");
  if (!route) return null;

  switch (route.name) {
    case "users":
      return <UsersList />;
    case "users.profile":
      return <UserProfile id={route.params.id} />;
    default:
      return null;
  }
}

// useNavigator — stable reference, never causes re-renders
function BackButton() {
  const navigator = useNavigator();
  return <button onClick={() => navigator.navigate("home")}>Back</button>;
}

// useRouterTransition — progress bars, loading states
function GlobalProgress() {
  const { isTransitioning } = useRouterTransition();
  if (!isTransitioning) return null;
  return <div className="progress-bar" />;
}

// useRouteExit — exit animations, draft autosave, AbortSignal-aware cleanup
function FadeOut() {
  const ref = useRef<HTMLDivElement>(null);
  useRouteExit(async ({ signal }) => {
    const el = ref.current;
    if (!el) return;
    el.classList.add("fade-out");
    const cleanup = () => el.classList.remove("fade-out");
    signal.addEventListener("abort", cleanup, { once: true });
    el.getBoundingClientRect(); // style flush
    await Promise.allSettled(el.getAnimations().map((a) => a.finished));
    cleanup();
  });
  return <div ref={ref}>...</div>;
}

// useRouteEnter — page-enter analytics, focus management, entry animations
function PageEnterAnalytics() {
  useRouteEnter(({ route, previousRoute }) => {
    analytics.track("page_enter", {
      route: route.name,
      from: previousRoute.name,
    });
  });
  return null;
}

Components

<Link>

Navigation link with automatic active state detection. Re-renders only when its active status changes.

<Link
  routeName="users.profile"
  routeParams={{ id: "123" }}
  activeClassName="active" // default: "active"
  activeStrict={false} // default: false (ancestor match)
  ignoreQueryParams={true} // default: true
  routeOptions={{ replace: true }}
>
  View Profile
</Link>
hash prop — URL fragment / tab-style UIs
<Link routeName="settings" hash="profile">Profile</Link>
<Link routeName="settings" hash="account">Account</Link>

Tri-state: undefined preserves the current hash, "" clears it, a value sets it. Active class is hash-aware — only the matching tab lights up. Live demo: examples/web/react/hash-examples/link-hash/ — behavior is identical across adapters, only template syntax differs. See the Hash Fragment Support wiki page for the full surface.

<RouteView>

Declarative route matching. Renders the first matching <RouteView.Match> child; falls back to <RouteView.Self> when the active route name equals nodeName, or <RouteView.NotFound> for UNKNOWN_ROUTE.

<RouteView nodeName="">
  <RouteView.Match segment="users">
    <UsersPage />
  </RouteView.Match>
  <RouteView.Match segment="settings">
    <SettingsPage />
  </RouteView.Match>
  <RouteView.NotFound>
    <NotFoundPage />
  </RouteView.NotFound>
</RouteView>

Note: Unlike the React adapter, keepAlive is not supported. Preact has no equivalent of React's <Activity> API. Components unmount completely when navigating away.

RouteView.Match props
Prop Type Required Description
segment string Yes Route segment to match
exact boolean No When true, matches only the exact route (not descendants). Default: false
fallback ComponentChildren No Shown while children suspend. Wraps children in <Suspense> when provided.
children ComponentChildren Yes Content to render when the active route matches segment
<RouteView.Self> and <RouteView.NotFound>

Three fallback slots compose inside a <RouteView nodeName="…">:

Element Fires when Props Render position
<RouteView.Match> Active route segment matches segment (or descendant when exact={false}) segment / exact / fallback / children Inline at source position
<RouteView.Self> Active route name exactly equals parent's nodeName fallback / children Appended after Match elements
<RouteView.NotFound> Active route name is UNKNOWN_ROUTE AND no Match activated children Appended after Match elements

Precedence:

  1. <Match> first-wins — duplicate segments short-circuit; subsequent <Match> with the same segment are not rendered.
  2. <Self> first-wins — only the first <RouteView.Self> contributes; subsequent ones are ignored.
  3. <NotFound> last-wins — when multiple <RouteView.NotFound> siblings are declared (unusual but legal), only the last one renders. Asymmetric with the other two slots; prefer a single <NotFound> per RouteView.
  4. An activating <Match> suppresses both <Self> and <NotFound>.
  5. When no <Match> activates: <Self> wins over <NotFound> if both would fire (occurs only when nodeName === UNKNOWN_ROUTE, narrow edge case).
<RouteView nodeName="users">
  <RouteView.Self>
    <UsersIndex />            {/* route name === "users" → renders */}
  </RouteView.Self>
  <RouteView.Match segment="profile">
    <UserProfile />           {/* "users.profile" and descendants → renders */}
  </RouteView.Match>
  <RouteView.NotFound>
    <NotFoundPage />          {/* UNKNOWN_ROUTE → renders */}
  </RouteView.NotFound>
</RouteView>
Lazy loading with fallback (experimental)

Preact's lazy and Suspense come from preact/compat. Support is experimental — test before shipping to production.

import { lazy } from "preact/compat";

const LazyDashboard = lazy(() => import("./Dashboard"));

<RouteView nodeName="">
  <RouteView.Match segment="dashboard" fallback={<Spinner />}>
    <LazyDashboard />
  </RouteView.Match>
</RouteView>;
Advanced exports

For custom integrations (e.g., writing your own hook on top of the router context), the low-level contexts are also exported:

import {
  RouterContext, // Raw Router instance
  NavigatorContext, // Navigator (stable ref)
  RouteContext, // { navigator, route, previousRoute }
  type RouteViewProps,
  type RouteViewMatchProps,
  type RouteViewSelfProps,
  type RouteViewNotFoundProps,
} from "@real-router/preact";

Most apps should prefer the use* hooks above over consuming contexts directly.

<RouterErrorBoundary>

Declarative error handling for navigation errors. Shows a fallback alongside children (not instead of) when a guard rejects or a route is not found.

import { RouterErrorBoundary } from "@real-router/preact";

<RouterErrorBoundary
  fallback={(error, resetError) => (
    <div className="toast">
      {error.code} <button onClick={resetError}>Dismiss</button>
    </div>
  )}
  onError={(error, toRoute, fromRoute) =>
    analytics.track("nav_error", {
      code: error.code,
      to: toRoute?.name,
      from: fromRoute?.name,
    })
  }
>
  <Link routeName="protected">Go to Protected</Link>
</RouterErrorBoundary>;

Auto-resets on next successful navigation. Works with both <Link> and imperative router.navigate().

SSR-feature surface — @real-router/preact/ssr

All SSR-aware components, hooks, and utilities live at the /ssr subpath — mirror of @real-router/react/ssr (same exports, same API). Eight exports total: <ClientOnly>, <ServerOnly>, <Streamed>, <Await>, <HttpStatusCode>, <HttpStatusProvider>, useDeferred, createHttpStatusSink.

<ClientOnly> / <ServerOnly>

Paired SSR-aware boundaries. <ClientOnly> renders fallback on the server (and on the client first paint, to match SSR HTML), then swaps in children after mount. <ServerOnly> is the symmetric inverse.

import { ClientOnly, ServerOnly } from "@real-router/preact/ssr";

<ClientOnly fallback={<Skeleton />}>
  <BrowserApiWidget />
</ClientOnly>

<ServerOnly>
  <SeoMetaStrip />
</ServerOnly>;

Implementation: useState(false) + useEffect(() => setMounted(true), []) from preact/hooks. End-to-end dogfooding lives in examples/web/preact/ssr-examples/ssr/ (see e2e/ssr-boundaries.spec.ts).

<Streamed> / <Await> / useDeferred

Three pieces of the deferred-data pipeline (paired with @real-router/ssr-data-plugin's defer() API). <Streamed> is a cross-adapter alias for Preact's <Suspense> (from preact/compat). <Await<T> name="key"> reads the deferred promise the loader published under that key and hands the resolved value to a render-prop via Preact's Suspense-throwing convention. useDeferred<T>(key) returns the same promise for callers composing with a third-party Suspense-aware lib.

import { Streamed, Await, useDeferred } from "@real-router/preact/ssr";

<Streamed fallback={<Spinner />}>
  <Await<Review[]> name="reviews">
    {(reviews) => <ReviewList items={reviews} />}
  </Await>
</Streamed>;

End-to-end example: examples/web/preact/ssr-examples/ssr-streaming/.

<HttpStatusCode> / <HttpStatusProvider> / createHttpStatusSink

Render-time HTTP status declaration for SSR responses. Mount <HttpStatusCode code={N} /> inside a route component (typical use: a <RouteView.NotFound> glob page) — it writes N to the nearest <HttpStatusProvider>'s sink during render and returns null. After renderToString, read sink.code and pass it to your response.

// app.tsx
import { HttpStatusCode } from "@real-router/preact/ssr";

function NotFound() {
  return (
    <>
      <HttpStatusCode code={404} />
      <h1>Page not found</h1>
    </>
  );
}

// entry-server.tsx
import { renderToString } from "preact-render-to-string";
import {
  HttpStatusProvider,
  createHttpStatusSink,
} from "@real-router/preact/ssr";

const sink = createHttpStatusSink();
const html = renderToString(
  <HttpStatusProvider sink={sink}>
    <RouterProvider router={router}>
      <App />
    </RouterProvider>
  </HttpStatusProvider>,
);
response.status(sink.code ?? 200).send(html);
Export Kind Purpose
<HttpStatusCode code={N}/> component Writes code to the optional context sink during render. Last write wins across multiple instances. No-op without a provider.
<HttpStatusProvider sink={…}> component Supplies an HttpStatusSink to descendant <HttpStatusCode /> via Preact context.
createHttpStatusSink() utility Returns a fresh { code: number | undefined } sink — construct one per request on the server, read sink.code after rendering.

Loader-driven errors (LoaderNotFound → 404, LoaderRedirect → 30x) keep working as before; this component covers render-time decisions only.

Accessibility

Enable screen reader announcements for route changes:

<RouterProvider router={router} announceNavigation>
  {/* Your app */}
</RouterProvider>

When enabled, a visually hidden aria-live region announces each navigation. Focus moves to the first <h1> on the new page. See Accessibility guide for details.

Scroll Restoration

Opt-in preservation of scroll position across navigations:

<RouterProvider router={router} scrollRestoration={{ mode: "restore" }}>
  {/* Your app */}
</RouterProvider>

Restores scroll on back/forward, scrolls to top (or #hash) on push. Three modes: "restore" (default), "top", "native". Custom containers via scrollContainer: () => HTMLElement | null. Override the sessionStorage key via storageKey (default "real-router:scroll") when isolating multiple routers on one origin. Lifecycle tied to the provider — created on mount, destroyed on unmount. Under @real-router/browser-plugin, replace transitions now preserve scroll position and programmatic reloads restore from sessionStorage (portable via state.transition.replace / state.transition.reload). See Scroll Restoration guide for the full behaviour matrix.

Scroll Spy

Opt-in router-coordinated IntersectionObserver scroll spy — the URL hash tracks the topmost visible anchor as the user scrolls, syncing state.context.url.hash so sibling <Link hash> highlights stay current:

<RouterProvider
  router={router}
  scrollSpy={{ selector: "[id]:is(h2,h3)" }}
>
  {/* Your app */}
</RouterProvider>

Emits a forced same-route transition with { hash, replace: true, force: true, hashChange: true } — the same write API as <Link hash> (#532), just with replace: true so the spy doesn't pollute history. Anti-flicker gates: isTransitioning (skip emits during transitions), coolingDown (skip emits during smooth scrollIntoView after a <Link hash> click; cleared on scrollend or 500 ms timeout), and selfEmitting (spy doesn't rate-limit itself). Hardcoded internals: IntersectionObserver.threshold = 0, rAF + 150 ms trailing debounce, MutationObserver re-observe debounced 250 ms.

Options: { selector: string, rootMargin?: string, scrollContainer?: () => HTMLElement | null }. Default rootMargin: "-20% 0px -60% 0px". Empty selector / undefined = off. SSR / browsers without IntersectionObserver = NOOP. Requires browser-plugin or navigation-plugin (hash-plugin / memory-plugin → warn-once + NOOP).

Behaviour is identical to the React adapter — see the React Scroll Spy demo (12 sections, TOC sidebar, 10 e2e scenarios) and the Scroll Spy guide.

View Transitions

Opt-in animated route transitions via the browser's View Transitions API:

<RouterProvider router={router} viewTransitions>
  {/* Your app */}
</RouterProvider>

No-op on unsupported browsers (Firefox as of 2026-04, SSR). Customization is pure CSS via ::view-transition-* pseudo-elements and view-transition-name for hero morphs. See View Transitions guide for patterns.

Documentation

Full documentation: Wiki

Examples

20 runnable examples — each is a standalone Vite app. Run: cd examples/web/preact/basic && pnpm dev

Routing fundamentals: basic · nested-routes · dynamic-routes · combined

Data & guards: auth-guards · async-guards · data-loading · lazy-loading · error-handling

URL features: hash-routing · persistent-params · search-schema

Animations: motion-animations · page-animations · route-animations · view-transitions

SSR: ssg · ssr · ssr-mixed · ssr-streaming

Package Description
@real-router/core Core router (required dependency)
@real-router/browser-plugin Browser History API integration
@real-router/sources Subscription layer (used internally)
@real-router/route-utils Route tree queries (useRouteUtils)

Contributing

See contributing guidelines for development setup and PR process.

License

MIT Oleg Ivanov

Keywords