@real-router/preload-plugin 0.2.1 → 0.3.0

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.
@@ -1,4 +1,5 @@
1
- import { Params, PluginFactory } from "@real-router/core";
1
+ import { DefaultDependencies, Params, Router } from "@real-router/types";
2
+ import { DefaultDependencies as DefaultDependencies$1, PluginFactory } from "@real-router/core";
2
3
 
3
4
  //#region src/types.d.ts
4
5
  interface PreloadPluginOptions {
@@ -7,19 +8,30 @@ interface PreloadPluginOptions {
7
8
  /** Check saveData/2g and disable preloading on slow connections. @default true */
8
9
  networkAware?: boolean;
9
10
  }
11
+ /**
12
+ * Preload function called when navigation intent is detected (hover, touch).
13
+ * Fire-and-forget: return values and errors are discarded.
14
+ */
15
+ type PreloadFn = (params: Params) => Promise<unknown>;
16
+ /**
17
+ * Factory function for creating preload hooks.
18
+ * Receives the router instance and a dependency getter (same pattern as GuardFnFactory).
19
+ * Factory runs once at first invocation; the returned function is cached per route.
20
+ */
21
+ type PreloadFnFactory<Dependencies extends DefaultDependencies = DefaultDependencies> = (router: Router<Dependencies>, getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K]) => PreloadFn;
10
22
  //#endregion
11
23
  //#region src/factory.d.ts
12
24
  declare function preloadPluginFactory(opts?: Partial<PreloadPluginOptions>): PluginFactory;
13
25
  //#endregion
14
26
  //#region src/index.d.ts
15
27
  declare module "@real-router/core" {
16
- interface Route {
17
- preload?: (params: Params) => Promise<unknown>;
28
+ interface Route<Dependencies extends DefaultDependencies$1> {
29
+ preload?: PreloadFnFactory<Dependencies>;
18
30
  }
19
31
  interface Router {
20
32
  getPreloadSettings(): PreloadPluginOptions;
21
33
  }
22
34
  } //# sourceMappingURL=index.d.ts.map
23
35
  //#endregion
24
- export { type PreloadPluginOptions, preloadPluginFactory };
36
+ export { type PreloadFn, type PreloadFnFactory, type PreloadPluginOptions, preloadPluginFactory };
25
37
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;UAAiB,oBAAA;;EAEf,KAAA;EAFe;EAIf,YAAA;AAAA;;;iBCIc,oBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,oBAAA,IACd,aAAA;;;;YCDS,KAAA;IACR,OAAA,IAAW,MAAA,EAAQ,MAAA,KAAW,OAAA;EAAA;EAAA,UAGtB,MAAA;IACR,kBAAA,IAAsB,oBAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;UAEiB,oBAAA;;EAEf,KAAA;EAFe;EAIf,YAAA;AAAA;;;AAOF;;KAAY,SAAA,IAAa,MAAA,EAAQ,MAAA,KAAW,OAAA;;;;;;KAOhC,gBAAA,sBACW,mBAAA,GAAsB,mBAAA,KAE3C,MAAA,EAAQ,MAAA,CAAO,YAAA,GACf,aAAA,mBAAgC,YAAA,EAAc,GAAA,EAAK,CAAA,KAAM,YAAA,CAAa,CAAA,MACnE,SAAA;;;iBCjBW,oBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,oBAAA,IACd,aAAA;;;;YCGS,KAAA,sBAA2B,qBAAA;IACnC,OAAA,GAAU,gBAAA,CAAiB,YAAA;EAAA;EAAA,UAGnB,MAAA;IACR,kBAAA,IAAsB,oBAAA;EAAA;AAAA"}
package/dist/cjs/index.js CHANGED
@@ -1,2 +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;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@real-router/core/api`);const t={delay:65,networkAware:!0},n={capture:!0,passive:!0};function r(){let e=navigator.connection;return e?!!(e.saveData||e.effectiveType?.includes(`2g`)):!1}var i=class{#e;#t;#n;#r;#i;#a=new Map;#o=null;#s=null;#c=null;#l=0;#u=null;#d=NaN;constructor(e,t,n,r){this.#e=e,this.#t=t,this.#n=n,this.#r=r;let i={...n};this.#i=t.extendRouter({getPreloadSettings:()=>i})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#f,n),document.addEventListener(`touchstart`,this.#p,n),document.addEventListener(`touchmove`,this.#m,n)},onStop:()=>{this.#x()},teardown:()=>{this.#x(),this.#i()}}}#f=e=>{if(this.#v(e))return;let t=this.#h(e.target);if(t===this.#o)return;this.#y(),this.#o=t;let n=this.#g(t);n&&(this.#s=setTimeout(()=>{this.#s=null,n.fn(n.params).catch(()=>{})},this.#n.delay))};#p=e=>{this.#u=e.target,this.#d=e.timeStamp,this.#b();let t=this.#h(e.target),n=this.#g(t);!n||e.touches.length===0||(this.#l=e.touches[0].clientY,this.#c=setTimeout(()=>{this.#c=null,n.fn(n.params).catch(()=>{})},100))};#m=e=>{this.#c===null||e.touches.length===0||Math.abs(e.touches[0].clientY-this.#l)>10&&this.#b()};#h(e){return e instanceof Element?e.closest(`a[href]`):null}#g(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&r()))return this.#_(e)}#_(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;let n=this.#t.getRouteConfig(t.name),r=typeof n?.preload==`function`?n.preload:void 0;if(!r){this.#a.delete(t.name);return}let i=this.#a.get(t.name);if(i?.factory===r)return{fn:i.fn,params:t.params};let a;try{a=r(this.#e,this.#r)}catch{return}return this.#a.set(t.name,{fn:a,factory:r}),{fn:a,params:t.params}}#v(e){let t=e.timeStamp-this.#d;return t>=0&&t<2500&&e.target===this.#u}#y(){this.#s!==null&&(clearTimeout(this.#s),this.#s=null),this.#o=null}#b(){this.#c!==null&&(clearTimeout(this.#c),this.#c=null)}#x(){document.removeEventListener(`mouseover`,this.#f,n),document.removeEventListener(`touchstart`,this.#p,n),document.removeEventListener(`touchmove`,this.#m,n),this.#y(),this.#b(),this.#u=null,this.#d=NaN}};function a(n){let r={...t,...n};return(!Number.isFinite(r.delay)||r.delay<0)&&(r.delay=0),function(t,n){return typeof document>`u`?{}:new i(t,(0,e.getPluginApi)(t),r,n).getPlugin()}}exports.preloadPluginFactory=a;
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +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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"}
1
+ {"version":3,"file":"index.js","names":["#router","#api","#options","#getDependency","#removeExtensions","#compiledPreloads","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#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\nexport const LISTENER_OPTIONS: AddEventListenerOptions = {\n capture: true,\n passive: true,\n};\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 LISTENER_OPTIONS,\n TOUCH_PRELOAD_DELAY,\n TOUCH_SCROLL_THRESHOLD,\n} from \"./constants\";\nimport { isSlowConnection } from \"./network\";\n\nimport type {\n PreloadFn,\n PreloadFnFactory,\n PreloadPluginOptions,\n} from \"./types\";\nimport type {\n Params,\n Plugin,\n PluginFactory,\n Router,\n State,\n} 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 #getDependency: Parameters<PluginFactory>[1];\n readonly #removeExtensions: () => void;\n readonly #compiledPreloads = new Map<\n string,\n { fn: PreloadFn; factory: PreloadFnFactory }\n >();\n\n #currentAnchor: HTMLAnchorElement | null = null;\n #hoverTimer: ReturnType<typeof setTimeout> | null = null;\n #touchTimer: ReturnType<typeof setTimeout> | null = null;\n #touchStartY = 0;\n #lastTouchTarget: EventTarget | null = null;\n #lastTouchTimeStamp = Number.NaN;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<PreloadPluginOptions>,\n getDependency: Parameters<PluginFactory>[1],\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n this.#getDependency = getDependency;\n\n const cachedOptions = { ...options };\n\n this.#removeExtensions = api.extendRouter({\n getPreloadSettings: () => cachedOptions,\n });\n }\n\n getPlugin(): Plugin {\n return {\n onStart: () => {\n document.addEventListener(\n \"mouseover\",\n this.#handleMouseOver,\n LISTENER_OPTIONS,\n );\n document.addEventListener(\n \"touchstart\",\n this.#handleTouchStart,\n LISTENER_OPTIONS,\n );\n document.addEventListener(\n \"touchmove\",\n this.#handleTouchMove,\n LISTENER_OPTIONS,\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 anchor = this.#findAnchor(event.target);\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.#lastTouchTarget = event.target;\n this.#lastTouchTimeStamp = event.timeStamp;\n\n this.#cancelTouch();\n\n const anchor = this.#findAnchor(event.target);\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload || event.touches.length === 0) {\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 || event.touches.length === 0) {\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 #findAnchor(target: EventTarget | null): HTMLAnchorElement | null {\n return target instanceof Element\n ? target.closest<HTMLAnchorElement>(\"a[href]\")\n : null;\n }\n\n #resolveAnchorPreload(\n anchor: HTMLAnchorElement | null | undefined,\n ): { fn: PreloadFn; 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: PreloadFn; 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 const factory =\n typeof config?.preload === \"function\"\n ? (config.preload as PreloadFnFactory)\n : undefined;\n\n if (!factory) {\n this.#compiledPreloads.delete(state.name);\n\n return undefined;\n }\n\n const cached = this.#compiledPreloads.get(state.name);\n\n if (cached?.factory === factory) {\n return { fn: cached.fn, params: state.params };\n }\n\n let fn: PreloadFn;\n\n try {\n fn = factory(this.#router, this.#getDependency);\n } catch {\n return undefined;\n }\n\n this.#compiledPreloads.set(state.name, { fn, factory });\n\n return { fn, params: state.params };\n }\n\n #isGhostMouseEvent(event: MouseEvent): boolean {\n const delta = event.timeStamp - this.#lastTouchTimeStamp;\n\n return (\n delta >= 0 &&\n delta < GHOST_EVENT_THRESHOLD &&\n event.target === this.#lastTouchTarget\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(\n \"mouseover\",\n this.#handleMouseOver,\n LISTENER_OPTIONS,\n );\n document.removeEventListener(\n \"touchstart\",\n this.#handleTouchStart,\n LISTENER_OPTIONS,\n );\n document.removeEventListener(\n \"touchmove\",\n this.#handleTouchMove,\n LISTENER_OPTIONS,\n );\n\n this.#cancelHover();\n this.#cancelTouch();\n this.#lastTouchTarget = null;\n this.#lastTouchTimeStamp = Number.NaN;\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 if (!Number.isFinite(options.delay) || options.delay < 0) {\n options.delay = 0;\n }\n\n return function preloadPlugin(routerBase, getDependency) {\n if (typeof document === \"undefined\") {\n return {};\n }\n\n const plugin = new PreloadPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n getDependency,\n );\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
- import { Params, PluginFactory } from "@real-router/core";
1
+ import { DefaultDependencies, Params, Router } from "@real-router/types";
2
+ import { DefaultDependencies as DefaultDependencies$1, PluginFactory } from "@real-router/core";
2
3
 
3
4
  //#region src/types.d.ts
4
5
  interface PreloadPluginOptions {
@@ -7,19 +8,30 @@ interface PreloadPluginOptions {
7
8
  /** Check saveData/2g and disable preloading on slow connections. @default true */
8
9
  networkAware?: boolean;
9
10
  }
11
+ /**
12
+ * Preload function called when navigation intent is detected (hover, touch).
13
+ * Fire-and-forget: return values and errors are discarded.
14
+ */
15
+ type PreloadFn = (params: Params) => Promise<unknown>;
16
+ /**
17
+ * Factory function for creating preload hooks.
18
+ * Receives the router instance and a dependency getter (same pattern as GuardFnFactory).
19
+ * Factory runs once at first invocation; the returned function is cached per route.
20
+ */
21
+ type PreloadFnFactory<Dependencies extends DefaultDependencies = DefaultDependencies> = (router: Router<Dependencies>, getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K]) => PreloadFn;
10
22
  //#endregion
11
23
  //#region src/factory.d.ts
12
24
  declare function preloadPluginFactory(opts?: Partial<PreloadPluginOptions>): PluginFactory;
13
25
  //#endregion
14
26
  //#region src/index.d.ts
15
27
  declare module "@real-router/core" {
16
- interface Route {
17
- preload?: (params: Params) => Promise<unknown>;
28
+ interface Route<Dependencies extends DefaultDependencies$1> {
29
+ preload?: PreloadFnFactory<Dependencies>;
18
30
  }
19
31
  interface Router {
20
32
  getPreloadSettings(): PreloadPluginOptions;
21
33
  }
22
34
  } //# sourceMappingURL=index.d.ts.map
23
35
  //#endregion
24
- export { type PreloadPluginOptions, preloadPluginFactory };
36
+ export { type PreloadFn, type PreloadFnFactory, type PreloadPluginOptions, preloadPluginFactory };
25
37
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;UAAiB,oBAAA;;EAEf,KAAA;EAFe;EAIf,YAAA;AAAA;;;iBCIc,oBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,oBAAA,IACd,aAAA;;;;YCDS,KAAA;IACR,OAAA,IAAW,MAAA,EAAQ,MAAA,KAAW,OAAA;EAAA;EAAA,UAGtB,MAAA;IACR,kBAAA,IAAsB,oBAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/types.ts","../../src/factory.ts","../../src/index.ts"],"mappings":";;;;UAEiB,oBAAA;;EAEf,KAAA;EAFe;EAIf,YAAA;AAAA;;;AAOF;;KAAY,SAAA,IAAa,MAAA,EAAQ,MAAA,KAAW,OAAA;;;;;;KAOhC,gBAAA,sBACW,mBAAA,GAAsB,mBAAA,KAE3C,MAAA,EAAQ,MAAA,CAAO,YAAA,GACf,aAAA,mBAAgC,YAAA,EAAc,GAAA,EAAK,CAAA,KAAM,YAAA,CAAa,CAAA,MACnE,SAAA;;;iBCjBW,oBAAA,CACd,IAAA,GAAO,OAAA,CAAQ,oBAAA,IACd,aAAA;;;;YCGS,KAAA,sBAA2B,qBAAA;IACnC,OAAA,GAAU,gBAAA,CAAiB,YAAA;EAAA;EAAA,UAGnB,MAAA;IACR,kBAAA,IAAsB,oBAAA;EAAA;AAAA"}
@@ -1,2 +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};
1
+ import{getPluginApi as e}from"@real-router/core/api";const t={delay:65,networkAware:!0},n={capture:!0,passive:!0};function r(){let e=navigator.connection;return e?!!(e.saveData||e.effectiveType?.includes(`2g`)):!1}var i=class{#e;#t;#n;#r;#i;#a=new Map;#o=null;#s=null;#c=null;#l=0;#u=null;#d=NaN;constructor(e,t,n,r){this.#e=e,this.#t=t,this.#n=n,this.#r=r;let i={...n};this.#i=t.extendRouter({getPreloadSettings:()=>i})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#f,n),document.addEventListener(`touchstart`,this.#p,n),document.addEventListener(`touchmove`,this.#m,n)},onStop:()=>{this.#x()},teardown:()=>{this.#x(),this.#i()}}}#f=e=>{if(this.#v(e))return;let t=this.#h(e.target);if(t===this.#o)return;this.#y(),this.#o=t;let n=this.#g(t);n&&(this.#s=setTimeout(()=>{this.#s=null,n.fn(n.params).catch(()=>{})},this.#n.delay))};#p=e=>{this.#u=e.target,this.#d=e.timeStamp,this.#b();let t=this.#h(e.target),n=this.#g(t);!n||e.touches.length===0||(this.#l=e.touches[0].clientY,this.#c=setTimeout(()=>{this.#c=null,n.fn(n.params).catch(()=>{})},100))};#m=e=>{this.#c===null||e.touches.length===0||Math.abs(e.touches[0].clientY-this.#l)>10&&this.#b()};#h(e){return e instanceof Element?e.closest(`a[href]`):null}#g(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&r()))return this.#_(e)}#_(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;let n=this.#t.getRouteConfig(t.name),r=typeof n?.preload==`function`?n.preload:void 0;if(!r){this.#a.delete(t.name);return}let i=this.#a.get(t.name);if(i?.factory===r)return{fn:i.fn,params:t.params};let a;try{a=r(this.#e,this.#r)}catch{return}return this.#a.set(t.name,{fn:a,factory:r}),{fn:a,params:t.params}}#v(e){let t=e.timeStamp-this.#d;return t>=0&&t<2500&&e.target===this.#u}#y(){this.#s!==null&&(clearTimeout(this.#s),this.#s=null),this.#o=null}#b(){this.#c!==null&&(clearTimeout(this.#c),this.#c=null)}#x(){document.removeEventListener(`mouseover`,this.#f,n),document.removeEventListener(`touchstart`,this.#p,n),document.removeEventListener(`touchmove`,this.#m,n),this.#y(),this.#b(),this.#u=null,this.#d=NaN}};function a(n){let r={...t,...n};return(!Number.isFinite(r.delay)||r.delay<0)&&(r.delay=0),function(t,n){return typeof document>`u`?{}:new i(t,e(t),r,n).getPlugin()}}export{a as preloadPluginFactory};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +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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"}
1
+ {"version":3,"file":"index.mjs","names":["#router","#api","#options","#getDependency","#removeExtensions","#compiledPreloads","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#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\nexport const LISTENER_OPTIONS: AddEventListenerOptions = {\n capture: true,\n passive: true,\n};\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 LISTENER_OPTIONS,\n TOUCH_PRELOAD_DELAY,\n TOUCH_SCROLL_THRESHOLD,\n} from \"./constants\";\nimport { isSlowConnection } from \"./network\";\n\nimport type {\n PreloadFn,\n PreloadFnFactory,\n PreloadPluginOptions,\n} from \"./types\";\nimport type {\n Params,\n Plugin,\n PluginFactory,\n Router,\n State,\n} 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 #getDependency: Parameters<PluginFactory>[1];\n readonly #removeExtensions: () => void;\n readonly #compiledPreloads = new Map<\n string,\n { fn: PreloadFn; factory: PreloadFnFactory }\n >();\n\n #currentAnchor: HTMLAnchorElement | null = null;\n #hoverTimer: ReturnType<typeof setTimeout> | null = null;\n #touchTimer: ReturnType<typeof setTimeout> | null = null;\n #touchStartY = 0;\n #lastTouchTarget: EventTarget | null = null;\n #lastTouchTimeStamp = Number.NaN;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<PreloadPluginOptions>,\n getDependency: Parameters<PluginFactory>[1],\n ) {\n this.#router = router;\n this.#api = api;\n this.#options = options;\n this.#getDependency = getDependency;\n\n const cachedOptions = { ...options };\n\n this.#removeExtensions = api.extendRouter({\n getPreloadSettings: () => cachedOptions,\n });\n }\n\n getPlugin(): Plugin {\n return {\n onStart: () => {\n document.addEventListener(\n \"mouseover\",\n this.#handleMouseOver,\n LISTENER_OPTIONS,\n );\n document.addEventListener(\n \"touchstart\",\n this.#handleTouchStart,\n LISTENER_OPTIONS,\n );\n document.addEventListener(\n \"touchmove\",\n this.#handleTouchMove,\n LISTENER_OPTIONS,\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 anchor = this.#findAnchor(event.target);\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.#lastTouchTarget = event.target;\n this.#lastTouchTimeStamp = event.timeStamp;\n\n this.#cancelTouch();\n\n const anchor = this.#findAnchor(event.target);\n const preload = this.#resolveAnchorPreload(anchor);\n\n if (!preload || event.touches.length === 0) {\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 || event.touches.length === 0) {\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 #findAnchor(target: EventTarget | null): HTMLAnchorElement | null {\n return target instanceof Element\n ? target.closest<HTMLAnchorElement>(\"a[href]\")\n : null;\n }\n\n #resolveAnchorPreload(\n anchor: HTMLAnchorElement | null | undefined,\n ): { fn: PreloadFn; 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: PreloadFn; 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 const factory =\n typeof config?.preload === \"function\"\n ? (config.preload as PreloadFnFactory)\n : undefined;\n\n if (!factory) {\n this.#compiledPreloads.delete(state.name);\n\n return undefined;\n }\n\n const cached = this.#compiledPreloads.get(state.name);\n\n if (cached?.factory === factory) {\n return { fn: cached.fn, params: state.params };\n }\n\n let fn: PreloadFn;\n\n try {\n fn = factory(this.#router, this.#getDependency);\n } catch {\n return undefined;\n }\n\n this.#compiledPreloads.set(state.name, { fn, factory });\n\n return { fn, params: state.params };\n }\n\n #isGhostMouseEvent(event: MouseEvent): boolean {\n const delta = event.timeStamp - this.#lastTouchTimeStamp;\n\n return (\n delta >= 0 &&\n delta < GHOST_EVENT_THRESHOLD &&\n event.target === this.#lastTouchTarget\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(\n \"mouseover\",\n this.#handleMouseOver,\n LISTENER_OPTIONS,\n );\n document.removeEventListener(\n \"touchstart\",\n this.#handleTouchStart,\n LISTENER_OPTIONS,\n );\n document.removeEventListener(\n \"touchmove\",\n this.#handleTouchMove,\n LISTENER_OPTIONS,\n );\n\n this.#cancelHover();\n this.#cancelTouch();\n this.#lastTouchTarget = null;\n this.#lastTouchTimeStamp = Number.NaN;\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 if (!Number.isFinite(options.delay) || options.delay < 0) {\n options.delay = 0;\n }\n\n return function preloadPlugin(routerBase, getDependency) {\n if (typeof document === \"undefined\") {\n return {};\n }\n\n const plugin = new PreloadPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n getDependency,\n );\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/preload-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "commonjs",
5
5
  "description": "Preload plugin — trigger data preloading on navigation intent (hover, touch)",
6
6
  "main": "./dist/cjs/index.js",
@@ -45,18 +45,20 @@
45
45
  },
46
46
  "sideEffects": false,
47
47
  "dependencies": {
48
- "@real-router/core": "^0.48.0"
48
+ "@real-router/core": "^0.48.0",
49
+ "@real-router/types": "^0.34.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "jsdom": "28.1.0"
52
53
  },
53
54
  "scripts": {
54
55
  "test": "vitest",
55
- "build": "tsdown --config-loader unrun",
56
56
  "type-check": "tsc --noEmit",
57
57
  "lint": "eslint --cache --ext .ts src/ tests/ --fix --max-warnings 0",
58
58
  "lint:package": "publint",
59
59
  "lint:types": "attw --pack .",
60
- "test:properties": "vitest --config vitest.config.properties.mts --run"
60
+ "test:properties": "vitest --config vitest.config.properties.mts --run",
61
+ "test:stress": "vitest --config vitest.config.stress.mts --run",
62
+ "bundle": "tsdown --config-loader unrun"
61
63
  }
62
64
  }
package/src/constants.ts CHANGED
@@ -10,3 +10,8 @@ export const GHOST_EVENT_THRESHOLD = 2500;
10
10
  export const TOUCH_SCROLL_THRESHOLD = 10;
11
11
 
12
12
  export const TOUCH_PRELOAD_DELAY = 100;
13
+
14
+ export const LISTENER_OPTIONS: AddEventListenerOptions = {
15
+ capture: true,
16
+ passive: true,
17
+ };
package/src/factory.ts CHANGED
@@ -14,7 +14,11 @@ export function preloadPluginFactory(
14
14
  ...opts,
15
15
  };
16
16
 
17
- return function preloadPlugin(routerBase) {
17
+ if (!Number.isFinite(options.delay) || options.delay < 0) {
18
+ options.delay = 0;
19
+ }
20
+
21
+ return function preloadPlugin(routerBase, getDependency) {
18
22
  if (typeof document === "undefined") {
19
23
  return {};
20
24
  }
@@ -23,6 +27,7 @@ export function preloadPluginFactory(
23
27
  routerBase as Router,
24
28
  getPluginApi(routerBase),
25
29
  options,
30
+ getDependency,
26
31
  );
27
32
 
28
33
  return plugin.getPlugin();
package/src/index.ts CHANGED
@@ -1,14 +1,18 @@
1
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";
2
+ import type { PreloadFnFactory, PreloadPluginOptions } from "./types";
3
+ import type { DefaultDependencies } from "@real-router/core";
4
4
 
5
5
  export { preloadPluginFactory } from "./factory";
6
6
 
7
- export type { PreloadPluginOptions } from "./types";
7
+ export type {
8
+ PreloadPluginOptions,
9
+ PreloadFn,
10
+ PreloadFnFactory,
11
+ } from "./types";
8
12
 
9
13
  declare module "@real-router/core" {
10
- interface Route {
11
- preload?: (params: Params) => Promise<unknown>;
14
+ interface Route<Dependencies extends DefaultDependencies> {
15
+ preload?: PreloadFnFactory<Dependencies>;
12
16
  }
13
17
 
14
18
  interface Router {
package/src/plugin.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  import {
2
2
  GHOST_EVENT_THRESHOLD,
3
+ LISTENER_OPTIONS,
3
4
  TOUCH_PRELOAD_DELAY,
4
5
  TOUCH_SCROLL_THRESHOLD,
5
6
  } from "./constants";
6
7
  import { isSlowConnection } from "./network";
7
8
 
8
- import type { PreloadPluginOptions } from "./types";
9
- import type { Params, Plugin, Router, State } from "@real-router/core";
9
+ import type {
10
+ PreloadFn,
11
+ PreloadFnFactory,
12
+ PreloadPluginOptions,
13
+ } from "./types";
14
+ import type {
15
+ Params,
16
+ Plugin,
17
+ PluginFactory,
18
+ Router,
19
+ State,
20
+ } from "@real-router/core";
10
21
  import type { PluginApi } from "@real-router/core/api";
11
22
 
12
23
  declare module "@real-router/core" {
@@ -19,46 +30,56 @@ export class PreloadPlugin {
19
30
  readonly #router: Router;
20
31
  readonly #api: PluginApi;
21
32
  readonly #options: Required<PreloadPluginOptions>;
33
+ readonly #getDependency: Parameters<PluginFactory>[1];
22
34
  readonly #removeExtensions: () => void;
35
+ readonly #compiledPreloads = new Map<
36
+ string,
37
+ { fn: PreloadFn; factory: PreloadFnFactory }
38
+ >();
23
39
 
24
40
  #currentAnchor: HTMLAnchorElement | null = null;
25
41
  #hoverTimer: ReturnType<typeof setTimeout> | null = null;
26
42
  #touchTimer: ReturnType<typeof setTimeout> | null = null;
27
43
  #touchStartY = 0;
28
- #lastTouchStartEvent: {
29
- target: EventTarget | null;
30
- timeStamp: number;
31
- } | null = null;
44
+ #lastTouchTarget: EventTarget | null = null;
45
+ #lastTouchTimeStamp = Number.NaN;
32
46
 
33
47
  constructor(
34
48
  router: Router,
35
49
  api: PluginApi,
36
50
  options: Required<PreloadPluginOptions>,
51
+ getDependency: Parameters<PluginFactory>[1],
37
52
  ) {
38
53
  this.#router = router;
39
54
  this.#api = api;
40
55
  this.#options = options;
56
+ this.#getDependency = getDependency;
57
+
58
+ const cachedOptions = { ...options };
41
59
 
42
60
  this.#removeExtensions = api.extendRouter({
43
- getPreloadSettings: () => ({ ...options }),
61
+ getPreloadSettings: () => cachedOptions,
44
62
  });
45
63
  }
46
64
 
47
65
  getPlugin(): Plugin {
48
66
  return {
49
67
  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
- });
68
+ document.addEventListener(
69
+ "mouseover",
70
+ this.#handleMouseOver,
71
+ LISTENER_OPTIONS,
72
+ );
73
+ document.addEventListener(
74
+ "touchstart",
75
+ this.#handleTouchStart,
76
+ LISTENER_OPTIONS,
77
+ );
78
+ document.addEventListener(
79
+ "touchmove",
80
+ this.#handleTouchMove,
81
+ LISTENER_OPTIONS,
82
+ );
62
83
  },
63
84
 
64
85
  onStop: () => {
@@ -77,15 +98,7 @@ export class PreloadPlugin {
77
98
  return;
78
99
  }
79
100
 
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]");
101
+ const anchor = this.#findAnchor(event.target);
89
102
 
90
103
  if (anchor === this.#currentAnchor) {
91
104
  return;
@@ -107,21 +120,15 @@ export class PreloadPlugin {
107
120
  };
108
121
 
109
122
  readonly #handleTouchStart = (event: TouchEvent): void => {
110
- this.#lastTouchStartEvent = {
111
- target: event.target,
112
- timeStamp: event.timeStamp,
113
- };
123
+ this.#lastTouchTarget = event.target;
124
+ this.#lastTouchTimeStamp = event.timeStamp;
114
125
 
115
126
  this.#cancelTouch();
116
127
 
117
- const target = event.target as Element | null;
118
- const anchor =
119
- target && "closest" in target
120
- ? target.closest<HTMLAnchorElement>("a[href]")
121
- : null;
128
+ const anchor = this.#findAnchor(event.target);
122
129
  const preload = this.#resolveAnchorPreload(anchor);
123
130
 
124
- if (!preload) {
131
+ if (!preload || event.touches.length === 0) {
125
132
  return;
126
133
  }
127
134
 
@@ -134,7 +141,7 @@ export class PreloadPlugin {
134
141
  };
135
142
 
136
143
  readonly #handleTouchMove = (event: TouchEvent): void => {
137
- if (this.#touchTimer === null) {
144
+ if (this.#touchTimer === null || event.touches.length === 0) {
138
145
  return;
139
146
  }
140
147
 
@@ -145,9 +152,15 @@ export class PreloadPlugin {
145
152
  }
146
153
  };
147
154
 
155
+ #findAnchor(target: EventTarget | null): HTMLAnchorElement | null {
156
+ return target instanceof Element
157
+ ? target.closest<HTMLAnchorElement>("a[href]")
158
+ : null;
159
+ }
160
+
148
161
  #resolveAnchorPreload(
149
162
  anchor: HTMLAnchorElement | null | undefined,
150
- ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {
163
+ ): { fn: PreloadFn; params: Params } | undefined {
151
164
  if (!anchor) {
152
165
  return undefined;
153
166
  }
@@ -165,7 +178,7 @@ export class PreloadPlugin {
165
178
 
166
179
  #resolvePreload(
167
180
  anchor: HTMLAnchorElement,
168
- ): { fn: (params: Params) => Promise<unknown>; params: Params } | undefined {
181
+ ): { fn: PreloadFn; params: Params } | undefined {
169
182
  const state = this.#router.matchUrl?.(anchor.href);
170
183
 
171
184
  if (!state) {
@@ -173,23 +186,43 @@ export class PreloadPlugin {
173
186
  }
174
187
 
175
188
  const config = this.#api.getRouteConfig(state.name);
189
+ const factory =
190
+ typeof config?.preload === "function"
191
+ ? (config.preload as PreloadFnFactory)
192
+ : undefined;
193
+
194
+ if (!factory) {
195
+ this.#compiledPreloads.delete(state.name);
176
196
 
177
- if (typeof config?.preload !== "function") {
178
197
  return undefined;
179
198
  }
180
199
 
181
- return {
182
- fn: config.preload as (params: Params) => Promise<unknown>,
183
- params: state.params,
184
- };
200
+ const cached = this.#compiledPreloads.get(state.name);
201
+
202
+ if (cached?.factory === factory) {
203
+ return { fn: cached.fn, params: state.params };
204
+ }
205
+
206
+ let fn: PreloadFn;
207
+
208
+ try {
209
+ fn = factory(this.#router, this.#getDependency);
210
+ } catch {
211
+ return undefined;
212
+ }
213
+
214
+ this.#compiledPreloads.set(state.name, { fn, factory });
215
+
216
+ return { fn, params: state.params };
185
217
  }
186
218
 
187
219
  #isGhostMouseEvent(event: MouseEvent): boolean {
220
+ const delta = event.timeStamp - this.#lastTouchTimeStamp;
221
+
188
222
  return (
189
- this.#lastTouchStartEvent !== null &&
190
- event.target === this.#lastTouchStartEvent.target &&
191
- event.timeStamp - this.#lastTouchStartEvent.timeStamp <
192
- GHOST_EVENT_THRESHOLD
223
+ delta >= 0 &&
224
+ delta < GHOST_EVENT_THRESHOLD &&
225
+ event.target === this.#lastTouchTarget
193
226
  );
194
227
  }
195
228
 
@@ -210,18 +243,25 @@ export class PreloadPlugin {
210
243
  }
211
244
 
212
245
  #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
- });
246
+ document.removeEventListener(
247
+ "mouseover",
248
+ this.#handleMouseOver,
249
+ LISTENER_OPTIONS,
250
+ );
251
+ document.removeEventListener(
252
+ "touchstart",
253
+ this.#handleTouchStart,
254
+ LISTENER_OPTIONS,
255
+ );
256
+ document.removeEventListener(
257
+ "touchmove",
258
+ this.#handleTouchMove,
259
+ LISTENER_OPTIONS,
260
+ );
222
261
 
223
262
  this.#cancelHover();
224
263
  this.#cancelTouch();
225
- this.#lastTouchStartEvent = null;
264
+ this.#lastTouchTarget = null;
265
+ this.#lastTouchTimeStamp = Number.NaN;
226
266
  }
227
267
  }
package/src/types.ts CHANGED
@@ -1,6 +1,26 @@
1
+ import type { DefaultDependencies, Params, Router } from "@real-router/types";
2
+
1
3
  export interface PreloadPluginOptions {
2
4
  /** Hover debounce delay in ms. @default 65 */
3
5
  delay?: number;
4
6
  /** Check saveData/2g and disable preloading on slow connections. @default true */
5
7
  networkAware?: boolean;
6
8
  }
9
+
10
+ /**
11
+ * Preload function called when navigation intent is detected (hover, touch).
12
+ * Fire-and-forget: return values and errors are discarded.
13
+ */
14
+ export type PreloadFn = (params: Params) => Promise<unknown>;
15
+
16
+ /**
17
+ * Factory function for creating preload hooks.
18
+ * Receives the router instance and a dependency getter (same pattern as GuardFnFactory).
19
+ * Factory runs once at first invocation; the returned function is cached per route.
20
+ */
21
+ export type PreloadFnFactory<
22
+ Dependencies extends DefaultDependencies = DefaultDependencies,
23
+ > = (
24
+ router: Router<Dependencies>,
25
+ getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],
26
+ ) => PreloadFn;