preload plugin - greydragon888/real-router GitHub Wiki
- Name: Preload Plugin
-
Package:
@real-router/preload-plugin -
Purpose: Trigger user-defined
preloadfunctions 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)
npm install @real-router/preload-plugin
# or
pnpm add @real-router/preload-pluginimport { 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.
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 |
interface PreloadPluginOptions {
delay?: number;
networkAware?: boolean;
}Importing the plugin package extends the Route and Router interfaces:
interface Route {
preload?: (params: Params) => Promise<unknown>;
}
interface Router {
getPreloadSettings(): PreloadPluginOptions;
}| 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 |
| Method | Returns | Description |
|---|---|---|
getPreloadSettings() |
PreloadPluginOptions |
Returns the resolved plugin options |
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)
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)
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.
-
mouseoverfires on the hovered element - Plugin finds the closest
<a href>ancestor - If the anchor changed from the previous hover, cancel any pending timer
- Resolve:
matchUrl(href)→getRouteConfig(name)→ checkpreloadexists - Start a debounce timer (
delayms, default 65) - On timer fire: call
preload(params)
Moving the mouse away (to another element or non-anchor) cancels the pending timer.
-
touchstartfires on the touched element - Plugin finds the closest
<a href>ancestor - Resolve route + preload function (same as hover)
- Start a short timer (100ms) for scroll detection
-
touchmovewith >10px vertical movement cancels the timer (user is scrolling) - 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.
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).
When networkAware: true (default), preloading is completely skipped if:
-
navigator.connection.saveDataistrue— user explicitly opted to reduce data usage -
navigator.connection.effectiveTypematches2gorslow-2g
If the navigator.connection API is not available, preloading proceeds normally (fail-open).
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>| 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 |
| 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 |
No ordering constraints, but browser-plugin (or hash-plugin) must be registered for preloading to work:
router.usePlugin(browserPluginFactory(), preloadPluginFactory({ delay: 100 }));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),
});
},
},
];const routes = [
{
name: "products.detail",
path: "/products/:slug",
preload: async (params) => {
await productStore.prefetch(params.slug);
},
},
];const routes = [
{
name: "dashboard",
path: "/dashboard",
preload: async () => {
await Promise.all([
queryClient.prefetchQuery({ queryKey: ["stats"], queryFn: fetchStats }),
queryClient.prefetchQuery({
queryKey: ["recent"],
queryFn: fetchRecent,
}),
]);
},
},
];router.usePlugin(
preloadPluginFactory({
delay: 150, // Wait 150ms before preloading (slower trigger)
}),
);router.usePlugin(
preloadPluginFactory({
networkAware: false, // Preload even on slow connections
}),
);const settings = router.getPreloadSettings();
console.log(settings.delay); // 65
console.log(settings.networkAware); // true- Plugin Architecture — How plugins integrate with the router
-
Route — Route configuration reference (
preloadis a custom field) - getRouteConfig — How plugins read custom route fields
- extendRouter — How plugins add methods to the router
-
@real-router/browser-plugin — Required for
matchUrl -
@real-router/lifecycle-plugin — Route-level lifecycle hooks (same
getRouteConfigpattern) - Data Loading — Data loading patterns in real-router