@real-router/preload-plugin 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @real-router/preload-plugin
2
+
3
+ [![npm](https://img.shields.io/npm/v/@real-router/preload-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/preload-plugin)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@real-router/preload-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/preload-plugin)
5
+ [![bundle size](https://deno.bundlejs.com/?q=@real-router/preload-plugin&treeshake=[*]&badge=detailed)](https://bundlejs.com/?q=@real-router/preload-plugin&treeshake=[*])
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](../../LICENSE)
7
+
8
+ > Preload on navigation intent for [Real-Router](https://github.com/greydragon888/real-router). Trigger data preloading when users hover over or touch links — before they click.
9
+
10
+ ```typescript
11
+ // Without plugin — data loads AFTER navigation:
12
+ // click → navigate → render → fetch → re-render (waterfall)
13
+
14
+ // With plugin — data loads BEFORE navigation:
15
+ // hover → preload → click → navigate → render (data ready)
16
+ ```
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @real-router/preload-plugin
22
+ ```
23
+
24
+ **Peer dependency:** `@real-router/core`
25
+
26
+ **Runtime dependency:** `@real-router/browser-plugin` must be registered (provides `matchUrl` for URL → route resolution).
27
+
28
+ ## Quick Start
29
+
30
+ ```typescript
31
+ import { createRouter } from "@real-router/core";
32
+ import { browserPluginFactory } from "@real-router/browser-plugin";
33
+ import { preloadPluginFactory } from "@real-router/preload-plugin";
34
+
35
+ const routes = [
36
+ {
37
+ name: "users.profile",
38
+ path: "/users/:id",
39
+ preload: async (params) => {
40
+ await queryClient.prefetchQuery({
41
+ queryKey: ["user", params.id],
42
+ queryFn: () => fetchUser(params.id),
43
+ });
44
+ },
45
+ },
46
+ {
47
+ name: "products.detail",
48
+ path: "/products/:slug",
49
+ preload: async (params) => {
50
+ await productStore.prefetch(params.slug);
51
+ },
52
+ },
53
+ ];
54
+
55
+ const router = createRouter(routes);
56
+ router.usePlugin(browserPluginFactory(), preloadPluginFactory());
57
+
58
+ await router.start();
59
+ ```
60
+
61
+ When a user hovers over a `<Link routeName="users.profile" routeParams={{ id: '123' }}>` for 65ms, the plugin calls `preload({ id: '123' })` — warming up your data layer before navigation.
62
+
63
+ ## Options
64
+
65
+ ```typescript
66
+ router.usePlugin(
67
+ preloadPluginFactory({
68
+ delay: 100, // Hover debounce in ms (default: 65)
69
+ networkAware: true, // Disable on Save-Data / 2G (default: true)
70
+ }),
71
+ );
72
+ ```
73
+
74
+ | Option | Type | Default | Description |
75
+ | -------------- | --------- | ------- | ---------------------------------------------------- |
76
+ | `delay` | `number` | `65` | Milliseconds to wait before triggering hover preload |
77
+ | `networkAware` | `boolean` | `true` | Skip preloading on Save-Data or 2G connections |
78
+
79
+ ## How It Works
80
+
81
+ ### Zero adapter changes
82
+
83
+ The plugin uses **DOM-level event delegation** — listeners on `document`, not on individual `<Link>` components. No modifications to React, Vue, Preact, Solid, or Svelte adapters.
84
+
85
+ ### Intent detection
86
+
87
+ | Trigger | Event | Timing | Rationale |
88
+ | --------- | ------------ | ------------------------- | ------------------------------ |
89
+ | **Hover** | `mouseover` | Debounced (configurable) | Filter accidental mouse passes |
90
+ | **Touch** | `touchstart` | ~100ms (scroll detection) | Touch = strong intent signal |
91
+
92
+ ### Route resolution
93
+
94
+ ```
95
+ anchor.href → router.matchUrl(href) → State → getRouteConfig(state.name)?.preload → call
96
+ ```
97
+
98
+ External links, routes without `preload`, and non-matching URLs are silently skipped.
99
+
100
+ ### Mobile support
101
+
102
+ - **Touch preloading**: `touchstart` triggers preload with minimal delay
103
+ - **Scroll detection**: `touchmove` with >10px vertical movement cancels pending preload
104
+ - **Ghost event suppression**: Synthetic `mouseover` events fired by mobile browsers after `touchstart` are suppressed (prevents double-preload)
105
+
106
+ All listeners use `{ passive: true }` — never blocks scrolling.
107
+
108
+ ### Network awareness
109
+
110
+ Preloading is automatically disabled when:
111
+
112
+ - `navigator.connection.saveData` is enabled
113
+ - `navigator.connection.effectiveType` is `2g` or `slow-2g`
114
+
115
+ Disable with `networkAware: false` if your preload functions handle this themselves.
116
+
117
+ ## Per-Link Opt-Out
118
+
119
+ ```html
120
+ <!-- Disable preloading for a specific link -->
121
+ <a href="/heavy-page" data-no-preload>Heavy Page</a>
122
+ ```
123
+
124
+ Works in all frameworks via prop pass-through:
125
+
126
+ ```tsx
127
+ <Link routeName="heavy" data-no-preload>
128
+ Heavy Page
129
+ </Link>
130
+ ```
131
+
132
+ ## Router Extension
133
+
134
+ The plugin adds one method to the router:
135
+
136
+ ```typescript
137
+ router.getPreloadSettings();
138
+ // → { delay: 65, networkAware: true }
139
+ ```
140
+
141
+ ## Data Layer Integration
142
+
143
+ The plugin is **data-agnostic** — it calls your `preload` function and doesn't care about the result. You control what happens inside:
144
+
145
+ ### TanStack Query
146
+
147
+ ```typescript
148
+ {
149
+ name: "users.profile",
150
+ path: "/users/:id",
151
+ preload: async (params) => {
152
+ await queryClient.prefetchQuery({
153
+ queryKey: ["user", params.id],
154
+ queryFn: () => fetchUser(params.id),
155
+ });
156
+ },
157
+ }
158
+ ```
159
+
160
+ ### Zustand / Pinia / Custom Store
161
+
162
+ ```typescript
163
+ {
164
+ name: "products.detail",
165
+ path: "/products/:slug",
166
+ preload: async (params) => {
167
+ await productStore.prefetch(params.slug);
168
+ },
169
+ }
170
+ ```
171
+
172
+ ### Multiple Concerns
173
+
174
+ ```typescript
175
+ {
176
+ name: "dashboard",
177
+ path: "/dashboard",
178
+ preload: async () => {
179
+ await Promise.all([
180
+ queryClient.prefetchQuery({ queryKey: ["stats"], queryFn: fetchStats }),
181
+ queryClient.prefetchQuery({ queryKey: ["recent"], queryFn: fetchRecent }),
182
+ ]);
183
+ },
184
+ }
185
+ ```
186
+
187
+ Errors in `preload` are silently caught — error handling is your data layer's responsibility.
188
+
189
+ ## SSR Support
190
+
191
+ The plugin is SSR-safe — returns an empty plugin object when `document` is not available:
192
+
193
+ ```typescript
194
+ // Server-side — no errors, no listeners
195
+ router.usePlugin(preloadPluginFactory());
196
+ ```
197
+
198
+ ## Graceful Degradation
199
+
200
+ Without `browser-plugin`, `router.matchUrl` is `undefined`. The plugin silently skips preloading via optional chaining — no errors, no warnings.
201
+
202
+ ## Documentation
203
+
204
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — Design decisions and data flow
205
+ - [Plugin Architecture](https://github.com/greydragon888/real-router/wiki/plugin-architecture) — How plugins integrate with the router
206
+
207
+ ## Related Packages
208
+
209
+ | Package | Description |
210
+ | -------------------------------------------------------------------------------------------- | -------------------------------------- |
211
+ | [@real-router/core](https://www.npmjs.com/package/@real-router/core) | Core router (required peer dependency) |
212
+ | [@real-router/browser-plugin](https://www.npmjs.com/package/@real-router/browser-plugin) | Browser plugin (provides `matchUrl`) |
213
+ | [@real-router/lifecycle-plugin](https://www.npmjs.com/package/@real-router/lifecycle-plugin) | Route-level lifecycle hooks |
214
+
215
+ ## Contributing
216
+
217
+ See [contributing guidelines](../../CONTRIBUTING.md) for development setup and PR process.
218
+
219
+ ## License
220
+
221
+ [MIT](../../LICENSE) © [Oleg Ivanov](https://github.com/greydragon888)
@@ -0,0 +1,25 @@
1
+ import { Params, PluginFactory } from "@real-router/core";
2
+
3
+ //#region src/types.d.ts
4
+ interface PreloadPluginOptions {
5
+ /** Hover debounce delay in ms. @default 65 */
6
+ delay?: number;
7
+ /** Check saveData/2g and disable preloading on slow connections. @default true */
8
+ networkAware?: boolean;
9
+ }
10
+ //#endregion
11
+ //#region src/factory.d.ts
12
+ declare function preloadPluginFactory(opts?: Partial<PreloadPluginOptions>): PluginFactory;
13
+ //#endregion
14
+ //#region src/index.d.ts
15
+ declare module "@real-router/core" {
16
+ interface Route {
17
+ preload?: (params: Params) => Promise<unknown>;
18
+ }
19
+ interface Router {
20
+ getPreloadSettings(): PreloadPluginOptions;
21
+ }
22
+ }
23
+ //#endregion
24
+ export { type PreloadPluginOptions, preloadPluginFactory };
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@real-router/core/api`);const t={delay:65,networkAware:!0};function n(){let e=navigator.connection;return e?!!(e.saveData||e.effectiveType?.includes(`2g`)):!1}var r=class{#e;#t;#n;#r;#i=null;#a=null;#o=null;#s=0;#c=null;constructor(e,t,n){this.#e=e,this.#t=t,this.#n=n,this.#r=t.extendRouter({getPreloadSettings:()=>({...n})})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#l,{capture:!0,passive:!0}),document.addEventListener(`touchstart`,this.#u,{capture:!0,passive:!0}),document.addEventListener(`touchmove`,this.#d,{capture:!0,passive:!0})},onStop:()=>{this.#_()},teardown:()=>{this.#_(),this.#r()}}}#l=e=>{if(this.#m(e))return;let t=e.target;if(!t||!(`closest`in t)){this.#h();return}let n=t.closest(`a[href]`);if(n===this.#i)return;this.#h(),this.#i=n;let r=this.#f(n);r&&(this.#a=setTimeout(()=>{this.#a=null,r.fn(r.params).catch(()=>{})},this.#n.delay))};#u=e=>{this.#c={target:e.target,timeStamp:e.timeStamp},this.#g();let t=e.target,n=t&&`closest`in t?t.closest(`a[href]`):null,r=this.#f(n);r&&(this.#s=e.touches[0].clientY,this.#o=setTimeout(()=>{this.#o=null,r.fn(r.params).catch(()=>{})},100))};#d=e=>{this.#o!==null&&Math.abs(e.touches[0].clientY-this.#s)>10&&this.#g()};#f(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&n()))return this.#p(e)}#p(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;let n=this.#t.getRouteConfig(t.name);if(typeof n?.preload==`function`)return{fn:n.preload,params:t.params}}#m(e){return this.#c!==null&&e.target===this.#c.target&&e.timeStamp-this.#c.timeStamp<2500}#h(){this.#a!==null&&(clearTimeout(this.#a),this.#a=null),this.#i=null}#g(){this.#o!==null&&(clearTimeout(this.#o),this.#o=null)}#_(){document.removeEventListener(`mouseover`,this.#l,{capture:!0}),document.removeEventListener(`touchstart`,this.#u,{capture:!0}),document.removeEventListener(`touchmove`,this.#d,{capture:!0}),this.#h(),this.#g(),this.#c=null}};function i(n){let i={...t,...n};return function(t){return typeof document>`u`?{}:new r(t,(0,e.getPluginApi)(t),i).getPlugin()}}exports.preloadPluginFactory=i;
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["#router","#api","#options","#removeExtensions","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#cancelHover","#currentAnchor","#resolveAnchorPreload","#hoverTimer","#lastTouchStartEvent","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload"],"sources":["../../src/constants.ts","../../src/network.ts","../../src/plugin.ts","../../src/factory.ts"],"sourcesContent":["import type { PreloadPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<PreloadPluginOptions> = {\n delay: 65,\n networkAware: true,\n};\n\nexport const GHOST_EVENT_THRESHOLD = 2500;\n\nexport const TOUCH_SCROLL_THRESHOLD = 10;\n\nexport const TOUCH_PRELOAD_DELAY = 100;\n","type NetworkConnection =\n | { saveData?: boolean; effectiveType?: string }\n | undefined;\n\ninterface NavigatorWithConnection extends Navigator {\n connection?: NetworkConnection;\n}\n\nexport function isSlowConnection(): boolean {\n const connection = (navigator as NavigatorWithConnection).connection;\n\n if (!connection) {\n return false;\n }\n if (connection.saveData) {\n return true;\n }\n if (connection.effectiveType?.includes(\"2g\")) {\n return true;\n }\n\n return false;\n}\n","import {\n GHOST_EVENT_THRESHOLD,\n TOUCH_PRELOAD_DELAY,\n TOUCH_SCROLL_THRESHOLD,\n} from \"./constants\";\nimport { isSlowConnection } from \"./network\";\n\nimport type { PreloadPluginOptions } from \"./types\";\nimport type { Params, Plugin, Router, State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ndeclare module \"@real-router/core\" {\n interface Router {\n matchUrl?: (url: string) => State | undefined;\n }\n}\n\nexport class PreloadPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<PreloadPluginOptions>;\n readonly #removeExtensions: () => void;\n\n #currentAnchor: HTMLAnchorElement | null = null;\n #hoverTimer: ReturnType<typeof setTimeout> | null = null;\n #touchTimer: ReturnType<typeof setTimeout> | null = null;\n #touchStartY = 0;\n #lastTouchStartEvent: {\n target: EventTarget | null;\n timeStamp: number;\n } | null = null;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<PreloadPluginOptions>,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n\n this.#removeExtensions = api.extendRouter({\n getPreloadSettings: () => ({ ...options }),\n });\n }\n\n getPlugin(): Plugin {\n return {\n onStart: () => {\n document.addEventListener(\"mouseover\", this.#handleMouseOver, {\n capture: true,\n passive: true,\n });\n document.addEventListener(\"touchstart\", this.#handleTouchStart, {\n capture: true,\n passive: true,\n });\n document.addEventListener(\"touchmove\", this.#handleTouchMove, {\n capture: true,\n passive: true,\n });\n },\n\n onStop: () => {\n this.#cleanup();\n },\n\n teardown: () => {\n this.#cleanup();\n this.#removeExtensions();\n },\n };\n }\n\n readonly #handleMouseOver = (event: MouseEvent): void => {\n if (this.#isGhostMouseEvent(event)) {\n return;\n }\n\n const target = event.target as Element | null;\n\n if (!target || !(\"closest\" in target)) {\n this.#cancelHover();\n\n return;\n }\n\n const anchor = target.closest<HTMLAnchorElement>(\"a[href]\");\n\n if (anchor === this.#currentAnchor) {\n return;\n }\n\n this.#cancelHover();\n this.#currentAnchor = anchor;\n\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload) {\n return;\n }\n\n this.#hoverTimer = setTimeout(() => {\n this.#hoverTimer = null;\n preload.fn(preload.params).catch(() => {});\n }, this.#options.delay);\n };\n\n readonly #handleTouchStart = (event: TouchEvent): void => {\n this.#lastTouchStartEvent = {\n target: event.target,\n timeStamp: event.timeStamp,\n };\n\n this.#cancelTouch();\n\n const target = event.target as Element | null;\n const anchor =\n target && \"closest\" in target\n ? target.closest<HTMLAnchorElement>(\"a[href]\")\n : null;\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload) {\n return;\n }\n\n this.#touchStartY = event.touches[0].clientY;\n\n this.#touchTimer = setTimeout(() => {\n this.#touchTimer = null;\n preload.fn(preload.params).catch(() => {});\n }, TOUCH_PRELOAD_DELAY);\n };\n\n readonly #handleTouchMove = (event: TouchEvent): void => {\n if (this.#touchTimer === null) {\n return;\n }\n\n const deltaY = Math.abs(event.touches[0].clientY - this.#touchStartY);\n\n if (deltaY > TOUCH_SCROLL_THRESHOLD) {\n this.#cancelTouch();\n }\n };\n\n #resolveAnchorPreload(\n anchor: HTMLAnchorElement | null | undefined,\n ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {\n if (!anchor) {\n return undefined;\n }\n\n if (\"noPreload\" in anchor.dataset) {\n return undefined;\n }\n\n if (this.#options.networkAware && isSlowConnection()) {\n return undefined;\n }\n\n return this.#resolvePreload(anchor);\n }\n\n #resolvePreload(\n anchor: HTMLAnchorElement,\n ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {\n const state = this.#router.matchUrl?.(anchor.href);\n\n if (!state) {\n return undefined;\n }\n\n const config = this.#api.getRouteConfig(state.name);\n\n if (typeof config?.preload !== \"function\") {\n return undefined;\n }\n\n return {\n fn: config.preload as (params: Params) => Promise<unknown>,\n params: state.params,\n };\n }\n\n #isGhostMouseEvent(event: MouseEvent): boolean {\n return (\n this.#lastTouchStartEvent !== null &&\n event.target === this.#lastTouchStartEvent.target &&\n event.timeStamp - this.#lastTouchStartEvent.timeStamp <\n GHOST_EVENT_THRESHOLD\n );\n }\n\n #cancelHover(): void {\n if (this.#hoverTimer !== null) {\n clearTimeout(this.#hoverTimer);\n this.#hoverTimer = null;\n }\n\n this.#currentAnchor = null;\n }\n\n #cancelTouch(): void {\n if (this.#touchTimer !== null) {\n clearTimeout(this.#touchTimer);\n this.#touchTimer = null;\n }\n }\n\n #cleanup(): void {\n document.removeEventListener(\"mouseover\", this.#handleMouseOver, {\n capture: true,\n });\n document.removeEventListener(\"touchstart\", this.#handleTouchStart, {\n capture: true,\n });\n document.removeEventListener(\"touchmove\", this.#handleTouchMove, {\n capture: true,\n });\n\n this.#cancelHover();\n this.#cancelTouch();\n this.#lastTouchStartEvent = null;\n }\n}\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { defaultOptions } from \"./constants\";\nimport { PreloadPlugin } from \"./plugin\";\n\nimport type { PreloadPluginOptions } from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function preloadPluginFactory(\n opts?: Partial<PreloadPluginOptions>,\n): PluginFactory {\n const options: Required<PreloadPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n return function preloadPlugin(routerBase) {\n if (typeof document === \"undefined\") {\n return {};\n }\n\n const plugin = new PreloadPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n );\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"0GAEA,MAAa,EAAiD,CAC5D,MAAO,GACP,aAAc,GACf,CCGD,SAAgB,GAA4B,CAC1C,IAAM,EAAc,UAAsC,WAY1D,OAVK,EAML,GAHI,EAAW,UAGX,EAAW,eAAe,SAAS,KAAK,EALnC,GCKX,IAAa,EAAb,KAA2B,CACzB,GACA,GACA,GACA,GAEA,GAA2C,KAC3C,GAAoD,KACpD,GAAoD,KACpD,GAAe,EACf,GAGW,KAEX,YACE,EACA,EACA,EACA,CACA,MAAA,EAAe,EACf,MAAA,EAAY,EACZ,MAAA,EAAgB,EAEhB,MAAA,EAAyB,EAAI,aAAa,CACxC,wBAA2B,CAAE,GAAG,EAAS,EAC1C,CAAC,CAGJ,WAAoB,CAClB,MAAO,CACL,YAAe,CACb,SAAS,iBAAiB,YAAa,MAAA,EAAuB,CAC5D,QAAS,GACT,QAAS,GACV,CAAC,CACF,SAAS,iBAAiB,aAAc,MAAA,EAAwB,CAC9D,QAAS,GACT,QAAS,GACV,CAAC,CACF,SAAS,iBAAiB,YAAa,MAAA,EAAuB,CAC5D,QAAS,GACT,QAAS,GACV,CAAC,EAGJ,WAAc,CACZ,MAAA,GAAe,EAGjB,aAAgB,CACd,MAAA,GAAe,CACf,MAAA,GAAwB,EAE3B,CAGH,GAA6B,GAA4B,CACvD,GAAI,MAAA,EAAwB,EAAM,CAChC,OAGF,IAAM,EAAS,EAAM,OAErB,GAAI,CAAC,GAAU,EAAE,YAAa,GAAS,CACrC,MAAA,GAAmB,CAEnB,OAGF,IAAM,EAAS,EAAO,QAA2B,UAAU,CAE3D,GAAI,IAAW,MAAA,EACb,OAGF,MAAA,GAAmB,CACnB,MAAA,EAAsB,EAEtB,IAAM,EAAU,MAAA,EAA2B,EAAO,CAE7C,IAIL,MAAA,EAAmB,eAAiB,CAClC,MAAA,EAAmB,KACnB,EAAQ,GAAG,EAAQ,OAAO,CAAC,UAAY,GAAG,EACzC,MAAA,EAAc,MAAM,GAGzB,GAA8B,GAA4B,CACxD,MAAA,EAA4B,CAC1B,OAAQ,EAAM,OACd,UAAW,EAAM,UAClB,CAED,MAAA,GAAmB,CAEnB,IAAM,EAAS,EAAM,OACf,EACJ,GAAU,YAAa,EACnB,EAAO,QAA2B,UAAU,CAC5C,KACA,EAAU,MAAA,EAA2B,EAAO,CAE7C,IAIL,MAAA,EAAoB,EAAM,QAAQ,GAAG,QAErC,MAAA,EAAmB,eAAiB,CAClC,MAAA,EAAmB,KACnB,EAAQ,GAAG,EAAQ,OAAO,CAAC,UAAY,GAAG,MACrB,GAGzB,GAA6B,GAA4B,CACnD,MAAA,IAAqB,MAIV,KAAK,IAAI,EAAM,QAAQ,GAAG,QAAU,MAAA,EAAkB,CAAA,IAGnE,MAAA,GAAmB,EAIvB,GACE,EAC0E,CACrE,MAID,gBAAe,EAAO,UAItB,QAAA,EAAc,cAAgB,GAAkB,EAIpD,OAAO,MAAA,EAAqB,EAAO,CAGrC,GACE,EAC0E,CAC1E,IAAM,EAAQ,MAAA,EAAa,WAAW,EAAO,KAAK,CAElD,GAAI,CAAC,EACH,OAGF,IAAM,EAAS,MAAA,EAAU,eAAe,EAAM,KAAK,CAE/C,UAAO,GAAQ,SAAY,WAI/B,MAAO,CACL,GAAI,EAAO,QACX,OAAQ,EAAM,OACf,CAGH,GAAmB,EAA4B,CAC7C,OACE,MAAA,IAA8B,MAC9B,EAAM,SAAW,MAAA,EAA0B,QAC3C,EAAM,UAAY,MAAA,EAA0B,UAAA,KAKhD,IAAqB,CACf,MAAA,IAAqB,OACvB,aAAa,MAAA,EAAiB,CAC9B,MAAA,EAAmB,MAGrB,MAAA,EAAsB,KAGxB,IAAqB,CACf,MAAA,IAAqB,OACvB,aAAa,MAAA,EAAiB,CAC9B,MAAA,EAAmB,MAIvB,IAAiB,CACf,SAAS,oBAAoB,YAAa,MAAA,EAAuB,CAC/D,QAAS,GACV,CAAC,CACF,SAAS,oBAAoB,aAAc,MAAA,EAAwB,CACjE,QAAS,GACV,CAAC,CACF,SAAS,oBAAoB,YAAa,MAAA,EAAuB,CAC/D,QAAS,GACV,CAAC,CAEF,MAAA,GAAmB,CACnB,MAAA,GAAmB,CACnB,MAAA,EAA4B,OCxNhC,SAAgB,EACd,EACe,CACf,IAAM,EAA0C,CAC9C,GAAG,EACH,GAAG,EACJ,CAED,OAAO,SAAuB,EAAY,CAWxC,OAVI,OAAO,SAAa,IACf,EAAE,CAGI,IAAI,EACjB,GAAA,EAAA,EAAA,cACa,EAAW,CACxB,EACD,CAEa,WAAW"}
@@ -0,0 +1,25 @@
1
+ import { Params, PluginFactory } from "@real-router/core";
2
+
3
+ //#region src/types.d.ts
4
+ interface PreloadPluginOptions {
5
+ /** Hover debounce delay in ms. @default 65 */
6
+ delay?: number;
7
+ /** Check saveData/2g and disable preloading on slow connections. @default true */
8
+ networkAware?: boolean;
9
+ }
10
+ //#endregion
11
+ //#region src/factory.d.ts
12
+ declare function preloadPluginFactory(opts?: Partial<PreloadPluginOptions>): PluginFactory;
13
+ //#endregion
14
+ //#region src/index.d.ts
15
+ declare module "@real-router/core" {
16
+ interface Route {
17
+ preload?: (params: Params) => Promise<unknown>;
18
+ }
19
+ interface Router {
20
+ getPreloadSettings(): PreloadPluginOptions;
21
+ }
22
+ }
23
+ //#endregion
24
+ export { type PreloadPluginOptions, preloadPluginFactory };
25
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,2 @@
1
+ import{getPluginApi as e}from"@real-router/core/api";const t={delay:65,networkAware:!0};function n(){let e=navigator.connection;return e?!!(e.saveData||e.effectiveType?.includes(`2g`)):!1}var r=class{#e;#t;#n;#r;#i=null;#a=null;#o=null;#s=0;#c=null;constructor(e,t,n){this.#e=e,this.#t=t,this.#n=n,this.#r=t.extendRouter({getPreloadSettings:()=>({...n})})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#l,{capture:!0,passive:!0}),document.addEventListener(`touchstart`,this.#u,{capture:!0,passive:!0}),document.addEventListener(`touchmove`,this.#d,{capture:!0,passive:!0})},onStop:()=>{this.#_()},teardown:()=>{this.#_(),this.#r()}}}#l=e=>{if(this.#m(e))return;let t=e.target;if(!t||!(`closest`in t)){this.#h();return}let n=t.closest(`a[href]`);if(n===this.#i)return;this.#h(),this.#i=n;let r=this.#f(n);r&&(this.#a=setTimeout(()=>{this.#a=null,r.fn(r.params).catch(()=>{})},this.#n.delay))};#u=e=>{this.#c={target:e.target,timeStamp:e.timeStamp},this.#g();let t=e.target,n=t&&`closest`in t?t.closest(`a[href]`):null,r=this.#f(n);r&&(this.#s=e.touches[0].clientY,this.#o=setTimeout(()=>{this.#o=null,r.fn(r.params).catch(()=>{})},100))};#d=e=>{this.#o!==null&&Math.abs(e.touches[0].clientY-this.#s)>10&&this.#g()};#f(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&n()))return this.#p(e)}#p(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;let n=this.#t.getRouteConfig(t.name);if(typeof n?.preload==`function`)return{fn:n.preload,params:t.params}}#m(e){return this.#c!==null&&e.target===this.#c.target&&e.timeStamp-this.#c.timeStamp<2500}#h(){this.#a!==null&&(clearTimeout(this.#a),this.#a=null),this.#i=null}#g(){this.#o!==null&&(clearTimeout(this.#o),this.#o=null)}#_(){document.removeEventListener(`mouseover`,this.#l,{capture:!0}),document.removeEventListener(`touchstart`,this.#u,{capture:!0}),document.removeEventListener(`touchmove`,this.#d,{capture:!0}),this.#h(),this.#g(),this.#c=null}};function i(n){let i={...t,...n};return function(t){return typeof document>`u`?{}:new r(t,e(t),i).getPlugin()}}export{i as preloadPluginFactory};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["#router","#api","#options","#removeExtensions","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#cancelHover","#currentAnchor","#resolveAnchorPreload","#hoverTimer","#lastTouchStartEvent","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload"],"sources":["../../src/constants.ts","../../src/network.ts","../../src/plugin.ts","../../src/factory.ts"],"sourcesContent":["import type { PreloadPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<PreloadPluginOptions> = {\n delay: 65,\n networkAware: true,\n};\n\nexport const GHOST_EVENT_THRESHOLD = 2500;\n\nexport const TOUCH_SCROLL_THRESHOLD = 10;\n\nexport const TOUCH_PRELOAD_DELAY = 100;\n","type NetworkConnection =\n | { saveData?: boolean; effectiveType?: string }\n | undefined;\n\ninterface NavigatorWithConnection extends Navigator {\n connection?: NetworkConnection;\n}\n\nexport function isSlowConnection(): boolean {\n const connection = (navigator as NavigatorWithConnection).connection;\n\n if (!connection) {\n return false;\n }\n if (connection.saveData) {\n return true;\n }\n if (connection.effectiveType?.includes(\"2g\")) {\n return true;\n }\n\n return false;\n}\n","import {\n GHOST_EVENT_THRESHOLD,\n TOUCH_PRELOAD_DELAY,\n TOUCH_SCROLL_THRESHOLD,\n} from \"./constants\";\nimport { isSlowConnection } from \"./network\";\n\nimport type { PreloadPluginOptions } from \"./types\";\nimport type { Params, Plugin, Router, State } from \"@real-router/core\";\nimport type { PluginApi } from \"@real-router/core/api\";\n\ndeclare module \"@real-router/core\" {\n interface Router {\n matchUrl?: (url: string) => State | undefined;\n }\n}\n\nexport class PreloadPlugin {\n readonly #router: Router;\n readonly #api: PluginApi;\n readonly #options: Required<PreloadPluginOptions>;\n readonly #removeExtensions: () => void;\n\n #currentAnchor: HTMLAnchorElement | null = null;\n #hoverTimer: ReturnType<typeof setTimeout> | null = null;\n #touchTimer: ReturnType<typeof setTimeout> | null = null;\n #touchStartY = 0;\n #lastTouchStartEvent: {\n target: EventTarget | null;\n timeStamp: number;\n } | null = null;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<PreloadPluginOptions>,\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n\n this.#removeExtensions = api.extendRouter({\n getPreloadSettings: () => ({ ...options }),\n });\n }\n\n getPlugin(): Plugin {\n return {\n onStart: () => {\n document.addEventListener(\"mouseover\", this.#handleMouseOver, {\n capture: true,\n passive: true,\n });\n document.addEventListener(\"touchstart\", this.#handleTouchStart, {\n capture: true,\n passive: true,\n });\n document.addEventListener(\"touchmove\", this.#handleTouchMove, {\n capture: true,\n passive: true,\n });\n },\n\n onStop: () => {\n this.#cleanup();\n },\n\n teardown: () => {\n this.#cleanup();\n this.#removeExtensions();\n },\n };\n }\n\n readonly #handleMouseOver = (event: MouseEvent): void => {\n if (this.#isGhostMouseEvent(event)) {\n return;\n }\n\n const target = event.target as Element | null;\n\n if (!target || !(\"closest\" in target)) {\n this.#cancelHover();\n\n return;\n }\n\n const anchor = target.closest<HTMLAnchorElement>(\"a[href]\");\n\n if (anchor === this.#currentAnchor) {\n return;\n }\n\n this.#cancelHover();\n this.#currentAnchor = anchor;\n\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload) {\n return;\n }\n\n this.#hoverTimer = setTimeout(() => {\n this.#hoverTimer = null;\n preload.fn(preload.params).catch(() => {});\n }, this.#options.delay);\n };\n\n readonly #handleTouchStart = (event: TouchEvent): void => {\n this.#lastTouchStartEvent = {\n target: event.target,\n timeStamp: event.timeStamp,\n };\n\n this.#cancelTouch();\n\n const target = event.target as Element | null;\n const anchor =\n target && \"closest\" in target\n ? target.closest<HTMLAnchorElement>(\"a[href]\")\n : null;\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload) {\n return;\n }\n\n this.#touchStartY = event.touches[0].clientY;\n\n this.#touchTimer = setTimeout(() => {\n this.#touchTimer = null;\n preload.fn(preload.params).catch(() => {});\n }, TOUCH_PRELOAD_DELAY);\n };\n\n readonly #handleTouchMove = (event: TouchEvent): void => {\n if (this.#touchTimer === null) {\n return;\n }\n\n const deltaY = Math.abs(event.touches[0].clientY - this.#touchStartY);\n\n if (deltaY > TOUCH_SCROLL_THRESHOLD) {\n this.#cancelTouch();\n }\n };\n\n #resolveAnchorPreload(\n anchor: HTMLAnchorElement | null | undefined,\n ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {\n if (!anchor) {\n return undefined;\n }\n\n if (\"noPreload\" in anchor.dataset) {\n return undefined;\n }\n\n if (this.#options.networkAware && isSlowConnection()) {\n return undefined;\n }\n\n return this.#resolvePreload(anchor);\n }\n\n #resolvePreload(\n anchor: HTMLAnchorElement,\n ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {\n const state = this.#router.matchUrl?.(anchor.href);\n\n if (!state) {\n return undefined;\n }\n\n const config = this.#api.getRouteConfig(state.name);\n\n if (typeof config?.preload !== \"function\") {\n return undefined;\n }\n\n return {\n fn: config.preload as (params: Params) => Promise<unknown>,\n params: state.params,\n };\n }\n\n #isGhostMouseEvent(event: MouseEvent): boolean {\n return (\n this.#lastTouchStartEvent !== null &&\n event.target === this.#lastTouchStartEvent.target &&\n event.timeStamp - this.#lastTouchStartEvent.timeStamp <\n GHOST_EVENT_THRESHOLD\n );\n }\n\n #cancelHover(): void {\n if (this.#hoverTimer !== null) {\n clearTimeout(this.#hoverTimer);\n this.#hoverTimer = null;\n }\n\n this.#currentAnchor = null;\n }\n\n #cancelTouch(): void {\n if (this.#touchTimer !== null) {\n clearTimeout(this.#touchTimer);\n this.#touchTimer = null;\n }\n }\n\n #cleanup(): void {\n document.removeEventListener(\"mouseover\", this.#handleMouseOver, {\n capture: true,\n });\n document.removeEventListener(\"touchstart\", this.#handleTouchStart, {\n capture: true,\n });\n document.removeEventListener(\"touchmove\", this.#handleTouchMove, {\n capture: true,\n });\n\n this.#cancelHover();\n this.#cancelTouch();\n this.#lastTouchStartEvent = null;\n }\n}\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { defaultOptions } from \"./constants\";\nimport { PreloadPlugin } from \"./plugin\";\n\nimport type { PreloadPluginOptions } from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\n\nexport function preloadPluginFactory(\n opts?: Partial<PreloadPluginOptions>,\n): PluginFactory {\n const options: Required<PreloadPluginOptions> = {\n ...defaultOptions,\n ...opts,\n };\n\n return function preloadPlugin(routerBase) {\n if (typeof document === \"undefined\") {\n return {};\n }\n\n const plugin = new PreloadPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n );\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@real-router/preload-plugin",
3
+ "version": "0.0.1",
4
+ "type": "commonjs",
5
+ "description": "Preload plugin — trigger data preloading on navigation intent (hover, touch)",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.mjs",
8
+ "types": "./dist/esm/index.d.mts",
9
+ "exports": {
10
+ ".": {
11
+ "development": "./src/index.ts",
12
+ "types": {
13
+ "import": "./dist/esm/index.d.mts",
14
+ "require": "./dist/cjs/index.d.ts"
15
+ },
16
+ "import": "./dist/esm/index.mjs",
17
+ "require": "./dist/cjs/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/greydragon888/real-router.git"
27
+ },
28
+ "keywords": [
29
+ "real-router",
30
+ "preload",
31
+ "prefetch",
32
+ "hover",
33
+ "touch",
34
+ "navigation-intent"
35
+ ],
36
+ "author": {
37
+ "name": "Oleg Ivanov",
38
+ "email": "greydragon888@gmail.com",
39
+ "url": "https://github.com/greydragon888"
40
+ },
41
+ "license": "MIT",
42
+ "homepage": "https://github.com/greydragon888/real-router",
43
+ "bugs": {
44
+ "url": "https://github.com/greydragon888/real-router/issues"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest",
48
+ "build": "tsdown --config-loader unrun",
49
+ "type-check": "tsc --noEmit",
50
+ "lint": "eslint --cache --ext .ts src/ tests/ --fix --max-warnings 0",
51
+ "lint:package": "publint",
52
+ "lint:types": "attw --pack .",
53
+ "build:dist-only": "tsdown --config-loader unrun"
54
+ },
55
+ "sideEffects": false,
56
+ "dependencies": {
57
+ "@real-router/core": "workspace:^"
58
+ },
59
+ "devDependencies": {
60
+ "jsdom": "28.1.0"
61
+ }
62
+ }
@@ -0,0 +1,12 @@
1
+ import type { PreloadPluginOptions } from "./types";
2
+
3
+ export const defaultOptions: Required<PreloadPluginOptions> = {
4
+ delay: 65,
5
+ networkAware: true,
6
+ };
7
+
8
+ export const GHOST_EVENT_THRESHOLD = 2500;
9
+
10
+ export const TOUCH_SCROLL_THRESHOLD = 10;
11
+
12
+ export const TOUCH_PRELOAD_DELAY = 100;
package/src/factory.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { getPluginApi } from "@real-router/core/api";
2
+
3
+ import { defaultOptions } from "./constants";
4
+ import { PreloadPlugin } from "./plugin";
5
+
6
+ import type { PreloadPluginOptions } from "./types";
7
+ import type { PluginFactory, Router } from "@real-router/core";
8
+
9
+ export function preloadPluginFactory(
10
+ opts?: Partial<PreloadPluginOptions>,
11
+ ): PluginFactory {
12
+ const options: Required<PreloadPluginOptions> = {
13
+ ...defaultOptions,
14
+ ...opts,
15
+ };
16
+
17
+ return function preloadPlugin(routerBase) {
18
+ if (typeof document === "undefined") {
19
+ return {};
20
+ }
21
+
22
+ const plugin = new PreloadPlugin(
23
+ routerBase as Router,
24
+ getPluginApi(routerBase),
25
+ options,
26
+ );
27
+
28
+ return plugin.getPlugin();
29
+ };
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /* eslint-disable @typescript-eslint/method-signature-style -- method syntax required for declaration merging overload (property syntax causes TS2717) */
2
+ import type { PreloadPluginOptions } from "./types";
3
+ import type { Params } from "@real-router/core";
4
+
5
+ export { preloadPluginFactory } from "./factory";
6
+
7
+ export type { PreloadPluginOptions } from "./types";
8
+
9
+ declare module "@real-router/core" {
10
+ interface Route {
11
+ preload?: (params: Params) => Promise<unknown>;
12
+ }
13
+
14
+ interface Router {
15
+ getPreloadSettings(): PreloadPluginOptions;
16
+ }
17
+ }
package/src/network.ts ADDED
@@ -0,0 +1,23 @@
1
+ type NetworkConnection =
2
+ | { saveData?: boolean; effectiveType?: string }
3
+ | undefined;
4
+
5
+ interface NavigatorWithConnection extends Navigator {
6
+ connection?: NetworkConnection;
7
+ }
8
+
9
+ export function isSlowConnection(): boolean {
10
+ const connection = (navigator as NavigatorWithConnection).connection;
11
+
12
+ if (!connection) {
13
+ return false;
14
+ }
15
+ if (connection.saveData) {
16
+ return true;
17
+ }
18
+ if (connection.effectiveType?.includes("2g")) {
19
+ return true;
20
+ }
21
+
22
+ return false;
23
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,227 @@
1
+ import {
2
+ GHOST_EVENT_THRESHOLD,
3
+ TOUCH_PRELOAD_DELAY,
4
+ TOUCH_SCROLL_THRESHOLD,
5
+ } from "./constants";
6
+ import { isSlowConnection } from "./network";
7
+
8
+ import type { PreloadPluginOptions } from "./types";
9
+ import type { Params, Plugin, Router, State } from "@real-router/core";
10
+ import type { PluginApi } from "@real-router/core/api";
11
+
12
+ declare module "@real-router/core" {
13
+ interface Router {
14
+ matchUrl?: (url: string) => State | undefined;
15
+ }
16
+ }
17
+
18
+ export class PreloadPlugin {
19
+ readonly #router: Router;
20
+ readonly #api: PluginApi;
21
+ readonly #options: Required<PreloadPluginOptions>;
22
+ readonly #removeExtensions: () => void;
23
+
24
+ #currentAnchor: HTMLAnchorElement | null = null;
25
+ #hoverTimer: ReturnType<typeof setTimeout> | null = null;
26
+ #touchTimer: ReturnType<typeof setTimeout> | null = null;
27
+ #touchStartY = 0;
28
+ #lastTouchStartEvent: {
29
+ target: EventTarget | null;
30
+ timeStamp: number;
31
+ } | null = null;
32
+
33
+ constructor(
34
+ router: Router,
35
+ api: PluginApi,
36
+ options: Required<PreloadPluginOptions>,
37
+ ) {
38
+ this.#router = router;
39
+ this.#api = api;
40
+ this.#options = options;
41
+
42
+ this.#removeExtensions = api.extendRouter({
43
+ getPreloadSettings: () => ({ ...options }),
44
+ });
45
+ }
46
+
47
+ getPlugin(): Plugin {
48
+ return {
49
+ onStart: () => {
50
+ document.addEventListener("mouseover", this.#handleMouseOver, {
51
+ capture: true,
52
+ passive: true,
53
+ });
54
+ document.addEventListener("touchstart", this.#handleTouchStart, {
55
+ capture: true,
56
+ passive: true,
57
+ });
58
+ document.addEventListener("touchmove", this.#handleTouchMove, {
59
+ capture: true,
60
+ passive: true,
61
+ });
62
+ },
63
+
64
+ onStop: () => {
65
+ this.#cleanup();
66
+ },
67
+
68
+ teardown: () => {
69
+ this.#cleanup();
70
+ this.#removeExtensions();
71
+ },
72
+ };
73
+ }
74
+
75
+ readonly #handleMouseOver = (event: MouseEvent): void => {
76
+ if (this.#isGhostMouseEvent(event)) {
77
+ return;
78
+ }
79
+
80
+ const target = event.target as Element | null;
81
+
82
+ if (!target || !("closest" in target)) {
83
+ this.#cancelHover();
84
+
85
+ return;
86
+ }
87
+
88
+ const anchor = target.closest<HTMLAnchorElement>("a[href]");
89
+
90
+ if (anchor === this.#currentAnchor) {
91
+ return;
92
+ }
93
+
94
+ this.#cancelHover();
95
+ this.#currentAnchor = anchor;
96
+
97
+ const preload = this.#resolveAnchorPreload(anchor);
98
+
99
+ if (!preload) {
100
+ return;
101
+ }
102
+
103
+ this.#hoverTimer = setTimeout(() => {
104
+ this.#hoverTimer = null;
105
+ preload.fn(preload.params).catch(() => {});
106
+ }, this.#options.delay);
107
+ };
108
+
109
+ readonly #handleTouchStart = (event: TouchEvent): void => {
110
+ this.#lastTouchStartEvent = {
111
+ target: event.target,
112
+ timeStamp: event.timeStamp,
113
+ };
114
+
115
+ this.#cancelTouch();
116
+
117
+ const target = event.target as Element | null;
118
+ const anchor =
119
+ target && "closest" in target
120
+ ? target.closest<HTMLAnchorElement>("a[href]")
121
+ : null;
122
+ const preload = this.#resolveAnchorPreload(anchor);
123
+
124
+ if (!preload) {
125
+ return;
126
+ }
127
+
128
+ this.#touchStartY = event.touches[0].clientY;
129
+
130
+ this.#touchTimer = setTimeout(() => {
131
+ this.#touchTimer = null;
132
+ preload.fn(preload.params).catch(() => {});
133
+ }, TOUCH_PRELOAD_DELAY);
134
+ };
135
+
136
+ readonly #handleTouchMove = (event: TouchEvent): void => {
137
+ if (this.#touchTimer === null) {
138
+ return;
139
+ }
140
+
141
+ const deltaY = Math.abs(event.touches[0].clientY - this.#touchStartY);
142
+
143
+ if (deltaY > TOUCH_SCROLL_THRESHOLD) {
144
+ this.#cancelTouch();
145
+ }
146
+ };
147
+
148
+ #resolveAnchorPreload(
149
+ anchor: HTMLAnchorElement | null | undefined,
150
+ ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {
151
+ if (!anchor) {
152
+ return undefined;
153
+ }
154
+
155
+ if ("noPreload" in anchor.dataset) {
156
+ return undefined;
157
+ }
158
+
159
+ if (this.#options.networkAware && isSlowConnection()) {
160
+ return undefined;
161
+ }
162
+
163
+ return this.#resolvePreload(anchor);
164
+ }
165
+
166
+ #resolvePreload(
167
+ anchor: HTMLAnchorElement,
168
+ ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {
169
+ const state = this.#router.matchUrl?.(anchor.href);
170
+
171
+ if (!state) {
172
+ return undefined;
173
+ }
174
+
175
+ const config = this.#api.getRouteConfig(state.name);
176
+
177
+ if (typeof config?.preload !== "function") {
178
+ return undefined;
179
+ }
180
+
181
+ return {
182
+ fn: config.preload as (params: Params) => Promise<unknown>,
183
+ params: state.params,
184
+ };
185
+ }
186
+
187
+ #isGhostMouseEvent(event: MouseEvent): boolean {
188
+ return (
189
+ this.#lastTouchStartEvent !== null &&
190
+ event.target === this.#lastTouchStartEvent.target &&
191
+ event.timeStamp - this.#lastTouchStartEvent.timeStamp <
192
+ GHOST_EVENT_THRESHOLD
193
+ );
194
+ }
195
+
196
+ #cancelHover(): void {
197
+ if (this.#hoverTimer !== null) {
198
+ clearTimeout(this.#hoverTimer);
199
+ this.#hoverTimer = null;
200
+ }
201
+
202
+ this.#currentAnchor = null;
203
+ }
204
+
205
+ #cancelTouch(): void {
206
+ if (this.#touchTimer !== null) {
207
+ clearTimeout(this.#touchTimer);
208
+ this.#touchTimer = null;
209
+ }
210
+ }
211
+
212
+ #cleanup(): void {
213
+ document.removeEventListener("mouseover", this.#handleMouseOver, {
214
+ capture: true,
215
+ });
216
+ document.removeEventListener("touchstart", this.#handleTouchStart, {
217
+ capture: true,
218
+ });
219
+ document.removeEventListener("touchmove", this.#handleTouchMove, {
220
+ capture: true,
221
+ });
222
+
223
+ this.#cancelHover();
224
+ this.#cancelTouch();
225
+ this.#lastTouchStartEvent = null;
226
+ }
227
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export interface PreloadPluginOptions {
2
+ /** Hover debounce delay in ms. @default 65 */
3
+ delay?: number;
4
+ /** Check saveData/2g and disable preloading on slow connections. @default true */
5
+ networkAware?: boolean;
6
+ }