@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 +221 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.d.mts +25 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +62 -0
- package/src/constants.ts +12 -0
- package/src/factory.ts +30 -0
- package/src/index.ts +17 -0
- package/src/network.ts +23 -0
- package/src/plugin.ts +227 -0
- package/src/types.ts +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# @real-router/preload-plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@real-router/preload-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/@real-router/preload-plugin)
|
|
5
|
+
[](https://bundlejs.com/?q=@real-router/preload-plugin&treeshake=[*])
|
|
6
|
+
[](../../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":"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"}
|
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|