preload plugin - greydragon888/real-router GitHub Wiki

@real-router/preload-plugin

1. Overview

  • Name: Preload Plugin
  • Package: @real-router/preload-plugin
  • Purpose: Trigger user-defined preload functions on navigation intent (hover, touch) via DOM-level event delegation — before the user actually navigates.
  • Typical scenarios:
    • Prefetching API data when the user hovers over a link (TanStack Query, SWR, custom stores)
    • Warming up a client-side cache before navigation
    • Preloading page-specific assets (images, JSON payloads)

2. Installation and Setup

npm install @real-router/preload-plugin
# or
pnpm add @real-router/preload-plugin
import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { preloadPluginFactory } from "@real-router/preload-plugin";

const routes = [
  {
    name: "users.profile",
    path: "/users/:id",
    preload: async (params) => {
      await queryClient.prefetchQuery({
        queryKey: ["user", params.id],
        queryFn: () => fetchUser(params.id),
      });
    },
  },
  {
    name: "products.detail",
    path: "/products/:slug",
    preload: async (params) => {
      await productStore.prefetch(params.slug);
    },
  },
];

const router = createRouter(routes);
router.usePlugin(browserPluginFactory(), preloadPluginFactory());

await router.start();

Requirement: @real-router/browser-plugin must be registered. The preload plugin uses router.matchUrl() (provided by browser-plugin) to resolve anchor hrefs to route states. Without it, preloading silently degrades to no-op.

3. Configuration

preloadPluginFactory(options?)
Option Type Default Description
delay number 65 Hover debounce delay in ms. Preload triggers only after the user stays on a link for this duration
networkAware boolean true When true, preloading is disabled on Save-Data or 2G connections

Types

interface PreloadPluginOptions {
  delay?: number;
  networkAware?: boolean;
}

Module Augmentation

Importing the plugin package extends the Route and Router interfaces:

interface Route {
  preload?: (params: Params) => Promise<unknown>;
}

interface Router {
  getPreloadSettings(): PreloadPluginOptions;
}

4. Lifecycle Hooks

Plugin Hook Implemented Description
onStart Adds mouseover, touchstart, touchmove listeners on document
onStop Removes listeners, clears pending timers, resets internal state
onTransitionStart
onTransitionLeaveApprove
onTransitionSuccess
onTransitionError
onTransitionCancel
teardown Same as onStop + removes getPreloadSettings router extension

5. Router Interaction

Router Interface Extension

Method Returns Description
getPreloadSettings() PreloadPluginOptions Returns the resolved plugin options

Route Resolution Flow

1. mouseover / touchstart on document (capture phase)
2. event.target → closest <a href="...">
3. router.matchUrl(anchor.href) → State | undefined
4. api.getRouteConfig(state.name)?.preload → function | undefined
5. setTimeout(delay) → preload(state.params)

Data Flow

Route definition: { name: "users", path: "/users/:id", preload: fn }
    │
    ├── Core extracts custom fields → routeCustomFields["users"] = { preload: fn }
    │
    └── Plugin reads via api.getRouteConfig("users")?.preload
        └── typeof === "function" → schedule fn(state.params)

6. Behavior

Event Delegation (Zero Adapter Changes)

All listeners are attached to document with { capture: true, passive: true }. No modifications to Link components in any framework adapter. The plugin works with any <a href="..."> element on the page — framework-rendered or static HTML.

Hover (Desktop)

  1. mouseover fires on the hovered element
  2. Plugin finds the closest <a href> ancestor
  3. If the anchor changed from the previous hover, cancel any pending timer
  4. Resolve: matchUrl(href)getRouteConfig(name) → check preload exists
  5. Start a debounce timer (delay ms, default 65)
  6. On timer fire: call preload(params)

Moving the mouse away (to another element or non-anchor) cancels the pending timer.

Touch (Mobile)

  1. touchstart fires on the touched element
  2. Plugin finds the closest <a href> ancestor
  3. Resolve route + preload function (same as hover)
  4. Start a short timer (100ms) for scroll detection
  5. touchmove with >10px vertical movement cancels the timer (user is scrolling)
  6. If timer fires without scroll: call preload(params)

Touch uses a shorter, fixed delay (100ms) instead of the configurable delay — the ~100-300ms gap between touchstart and click is already a natural debounce.

Ghost Mouse Event Suppression

Mobile browsers fire synthetic mouseover and mousedown events after a touchstart for backward compatibility. Without suppression, every mobile tap would trigger preload twice.

The plugin records the last touchstart target and timestamp. Any mouseover from the same target within 2500ms is silently ignored. The 2500ms window covers even heavily loaded devices (tested up to 1450ms delay on Samsung Galaxy S2).

Network Awareness

When networkAware: true (default), preloading is completely skipped if:

  • navigator.connection.saveData is true — user explicitly opted to reduce data usage
  • navigator.connection.effectiveType matches 2g or slow-2g

If the navigator.connection API is not available, preloading proceeds normally (fail-open).

Per-Link Opt-Out

Add data-no-preload to any anchor to disable preloading for that specific link:

<a href="/heavy-page" data-no-preload>Heavy Page</a>

Works in all frameworks via prop pass-through:

{
  /* React / Preact / Solid */
}
<Link routeName="heavy" data-no-preload>
  Heavy Page
</Link>;
<!-- Vue -->
<Link route-name="heavy" data-no-preload>Heavy Page</Link>
<!-- Svelte -->
<Link routeName="heavy" data-no-preload>Heavy Page</Link>

Edge Cases

Scenario Behavior
Route without preload field Silently skipped
External link (different origin) matchUrl returns undefined → skipped
Link with data-no-preload Skipped before route resolution
preload throws an error Caught silently (.catch(() => {})) — data-layer concern
Hover → leave before delay Timer cancelled, preload never fires
Hover anchor A → hover anchor B A's timer cancelled, B's timer starts
Touch → scroll (>10px vertical) Touch timer cancelled
Touch → synthetic mouseover Ghost event suppressed (2500ms window)
Save-Data enabled / 2G connection All preloading disabled
No navigator.connection API Preloading works normally
browser-plugin not registered matchUrl is undefined → graceful no-op
SSR (no document) Factory returns empty plugin {}
networkAware: false Network checks bypassed
Same anchor hovered repeatedly Timer not restarted (dedup by tracking currentAnchor)
Non-Element event target (text node, etc) closest guard skips safely

7. Integration with Other Plugins

Plugin Interaction
@real-router/browser-plugin (required) Provides matchUrl for URL → route resolution
@real-router/hash-plugin Works if matchUrl is available (hash-plugin provides it)
@real-router/lifecycle-plugin Independent — preload fires on hover intent, lifecycle hooks fire on navigation
@real-router/logger-plugin Independent — preload is a side-effect, not a transition
@real-router/validation-plugin Independent

Registration Order

No ordering constraints, but browser-plugin (or hash-plugin) must be registered for preloading to work:

router.usePlugin(browserPluginFactory(), preloadPluginFactory({ delay: 100 }));

8. Usage Examples

TanStack Query

import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

const routes = [
  {
    name: "users.profile",
    path: "/users/:id",
    preload: async (params) => {
      await queryClient.prefetchQuery({
        queryKey: ["user", params.id],
        queryFn: () => fetchUser(params.id),
      });
    },
  },
];

Zustand / Pinia Store

const routes = [
  {
    name: "products.detail",
    path: "/products/:slug",
    preload: async (params) => {
      await productStore.prefetch(params.slug);
    },
  },
];

Multiple Concerns

const routes = [
  {
    name: "dashboard",
    path: "/dashboard",
    preload: async () => {
      await Promise.all([
        queryClient.prefetchQuery({ queryKey: ["stats"], queryFn: fetchStats }),
        queryClient.prefetchQuery({
          queryKey: ["recent"],
          queryFn: fetchRecent,
        }),
      ]);
    },
  },
];

Custom Delay

router.usePlugin(
  preloadPluginFactory({
    delay: 150, // Wait 150ms before preloading (slower trigger)
  }),
);

Disable Network Awareness

router.usePlugin(
  preloadPluginFactory({
    networkAware: false, // Preload even on slow connections
  }),
);

Reading Plugin Settings

const settings = router.getPreloadSettings();
console.log(settings.delay); // 65
console.log(settings.networkAware); // true

See Also

⚠️ **GitHub.com Fallback** ⚠️