@real-router/preload-plugin 0.4.3 → 0.5.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.
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/constants.ts +0 -17
- package/src/factory.ts +0 -35
- package/src/index.ts +0 -22
- package/src/network.ts +0 -23
- package/src/plugin.ts +0 -304
- package/src/types.ts +0 -26
package/dist/cjs/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(
|
|
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;#o=new Map;#s=new Map;#c=null;#l=null;#u=null;#d=0;#f=null;#p=NaN;constructor(t,n,r,i){this.#e=t,this.#t=n,this.#n=r,this.#r=i;let a={...r};this.#i=n.extendRouter({getPreloadSettings:()=>a,getPreloadedState:e=>{let t=this.#s.get(e);return t&&this.#s.delete(e),t}}),this.#a=(0,e.getRoutesApi)(t).subscribeChanges(e=>{this.#m(e)})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#g,n),document.addEventListener(`touchstart`,this.#_,n),document.addEventListener(`touchmove`,this.#v,n)},onStop:()=>{this.#E()},teardown:()=>{this.#E(),this.#a(),this.#i()}}}#m(e){switch(e.op){case`remove`:for(let t of e.removedSubtree)this.#o.delete(t.name);this.#h();break;case`replace`:for(let t of e.removed)this.#o.delete(t.name);this.#h();break;case`clear`:this.#o.clear(),this.#h();break}}#h(){this.#s.clear()}#g=e=>{if(this.#C(e))return;let t=this.#y(e.target);if(t===this.#c)return;this.#w(),this.#c=t;let n=this.#b(t);n&&(this.#l=setTimeout(()=>{this.#l=null,n.fn(n.params).catch(()=>{})},this.#n.delay))};#_=e=>{this.#f=e.target,this.#p=e.timeStamp,this.#T();let t=this.#y(e.target),n=this.#b(t);!n||e.touches.length===0||(this.#d=e.touches[0].clientY,this.#u=setTimeout(()=>{this.#u=null,n.fn(n.params).catch(()=>{})},100))};#v=e=>{this.#u===null||e.touches.length===0||Math.abs(e.touches[0].clientY-this.#d)>10&&this.#T()};#y(e){return e instanceof Element?e.closest(`a[href]`):null}#b(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&r()))return this.#x(e)}#x(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;this.#S(e.href,t);let n=this.#t.getRouteConfig(t.name),r=typeof n?.preload==`function`?n.preload:void 0;if(!r){this.#o.delete(t.name);return}let i=this.#o.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.#o.set(t.name,{fn:a,factory:r}),{fn:a,params:t.params}}#S(e,t){if(this.#s.has(e))this.#s.delete(e);else if(this.#s.size>=32)for(let e of this.#s.keys()){this.#s.delete(e);break}this.#s.set(e,t)}#C(e){let t=e.timeStamp-this.#p;return t>=0&&t<2500&&e.target===this.#f}#w(){this.#l!==null&&(clearTimeout(this.#l),this.#l=null),this.#c=null}#T(){this.#u!==null&&(clearTimeout(this.#u),this.#u=null)}#E(){document.removeEventListener(`mouseover`,this.#g,n),document.removeEventListener(`touchstart`,this.#_,n),document.removeEventListener(`touchmove`,this.#v,n),this.#w(),this.#T(),this.#f=null,this.#p=NaN,this.#s.clear()}};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
|
package/dist/cjs/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["#router","#api","#options","#getDependency","#removeExtensions","#compiledPreloads","#stateCache","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload","#cacheState"],"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\nconst STATE_CACHE_LIMIT = 32;\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 // Pre-resolved State cache keyed by anchor href. Populated when a hover/touch\n // resolves a route via router.matchUrl, consumed once via\n // router.getPreloadedState(href). Single-use semantics (delete-on-read) keep\n // the cache from drifting out of sync with current world state — once the\n // consumer commits the snapshot via api.navigateToState, the entry is gone.\n // Bounded with insertion-order eviction (#562).\n readonly #stateCache = new Map<string, State>();\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 getPreloadedState: (href: string): State | undefined => {\n const state = this.#stateCache.get(href);\n\n if (state) {\n this.#stateCache.delete(href);\n }\n\n return state;\n },\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 this.#cacheState(anchor.href, state);\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 #cacheState(href: string, state: State): void {\n // Re-insert to refresh recency ordering (Map iteration is insertion order).\n if (this.#stateCache.has(href)) {\n this.#stateCache.delete(href);\n } else if (this.#stateCache.size >= STATE_CACHE_LIMIT) {\n // size >= LIMIT > 0 → iterator has at least one key.\n for (const oldest of this.#stateCache.keys()) {\n this.#stateCache.delete(oldest);\n\n break;\n }\n }\n\n this.#stateCache.set(href, state);\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 this.#stateCache.clear();\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":"0GAEA,MAAa,EAAiD,CAC5D,MAAO,GACP,aAAc,EAChB,EAQa,EAA4C,CACvD,QAAS,GACT,QAAS,EACX,ECRA,SAAgB,GAA4B,CAC1C,IAAM,EAAc,UAAsC,WAY1D,OAVK,EAML,GAHI,EAAW,UAGX,EAAW,eAAe,SAAS,IAAI,GALlC,EAUX,CCQA,IAAa,EAAb,KAA2B,CACzB,GACA,GACA,GACA,GACA,GACA,GAA6B,IAAI,IAUjC,GAAuB,IAAI,IAE3B,GAA2C,KAC3C,GAAoD,KACpD,GAAoD,KACpD,GAAe,EACf,GAAuC,KACvC,GAAsB,IAEtB,YACE,EACA,EACA,EACA,EACA,CACA,KAAKA,GAAU,EACf,KAAKC,GAAO,EACZ,KAAKC,GAAW,EAChB,KAAKC,GAAiB,EAEtB,IAAM,EAAgB,CAAE,GAAG,CAAQ,EAEnC,KAAKC,GAAoB,EAAI,aAAa,CACxC,uBAA0B,EAC1B,kBAAoB,GAAoC,CACtD,IAAM,EAAQ,KAAKE,GAAY,IAAI,CAAI,EAMvC,OAJI,GACF,KAAKA,GAAY,OAAO,CAAI,EAGvB,CACT,CACF,CAAC,CACH,CAEA,WAAoB,CAClB,MAAO,CACL,YAAe,CACb,SAAS,iBACP,YACA,KAAKC,GACL,CACF,EACA,SAAS,iBACP,aACA,KAAKC,GACL,CACF,EACA,SAAS,iBACP,YACA,KAAKC,GACL,CACF,CACF,EAEA,WAAc,CACZ,KAAKC,GAAS,CAChB,EAEA,aAAgB,CACd,KAAKA,GAAS,EACd,KAAKN,GAAkB,CACzB,CACF,CACF,CAEA,GAA6B,GAA4B,CACvD,GAAI,KAAKO,GAAmB,CAAK,EAC/B,OAGF,IAAM,EAAS,KAAKC,GAAY,EAAM,MAAM,EAE5C,GAAI,IAAW,KAAKC,GAClB,OAGF,KAAKC,GAAa,EAClB,KAAKD,GAAiB,EAEtB,IAAM,EAAU,KAAKE,GAAsB,CAAM,EAE5C,IAIL,KAAKC,GAAc,eAAiB,CAClC,KAAKA,GAAc,KACnB,EAAQ,GAAG,EAAQ,MAAM,EAAE,UAAY,CAAC,CAAC,CAC3C,EAAG,KAAKd,GAAS,KAAK,EACxB,EAEA,GAA8B,GAA4B,CACxD,KAAKe,GAAmB,EAAM,OAC9B,KAAKC,GAAsB,EAAM,UAEjC,KAAKC,GAAa,EAElB,IAAM,EAAS,KAAKP,GAAY,EAAM,MAAM,EACtC,EAAU,KAAKG,GAAsB,CAAM,EAE7C,CAAC,GAAW,EAAM,QAAQ,SAAW,IAIzC,KAAKK,GAAe,EAAM,QAAQ,GAAG,QAErC,KAAKC,GAAc,eAAiB,CAClC,KAAKA,GAAc,KACnB,EAAQ,GAAG,EAAQ,MAAM,EAAE,UAAY,CAAC,CAAC,CAC3C,EAAA,GAAsB,EACxB,EAEA,GAA6B,GAA4B,CACnD,KAAKA,KAAgB,MAAQ,EAAM,QAAQ,SAAW,GAI3C,KAAK,IAAI,EAAM,QAAQ,GAAG,QAAU,KAAKD,EAE/C,EAAA,IACP,KAAKD,GAAa,CAEtB,EAEA,GAAY,EAAsD,CAChE,OAAO,aAAkB,QACrB,EAAO,QAA2B,SAAS,EAC3C,IACN,CAEA,GACE,EAC+C,CAC1C,MAID,gBAAe,EAAO,UAItB,OAAKjB,GAAS,cAAgB,EAAiB,GAInD,OAAO,KAAKoB,GAAgB,CAAM,CACpC,CAEA,GACE,EAC+C,CAC/C,IAAM,EAAQ,KAAKtB,GAAQ,WAAW,EAAO,IAAI,EAEjD,GAAI,CAAC,EACH,OAGF,KAAKuB,GAAY,EAAO,KAAM,CAAK,EAEnC,IAAM,EAAS,KAAKtB,GAAK,eAAe,EAAM,IAAI,EAC5C,EACJ,OAAO,GAAQ,SAAY,WACtB,EAAO,QACR,IAAA,GAEN,GAAI,CAAC,EAAS,CACZ,KAAKI,GAAkB,OAAO,EAAM,IAAI,EAExC,MACF,CAEA,IAAM,EAAS,KAAKA,GAAkB,IAAI,EAAM,IAAI,EAEpD,GAAI,GAAQ,UAAY,EACtB,MAAO,CAAE,GAAI,EAAO,GAAI,OAAQ,EAAM,MAAO,EAG/C,IAAI,EAEJ,GAAI,CACF,EAAK,EAAQ,KAAKL,GAAS,KAAKG,EAAc,CAChD,MAAQ,CACN,MACF,CAIA,OAFA,KAAKE,GAAkB,IAAI,EAAM,KAAM,CAAE,KAAI,SAAQ,CAAC,EAE/C,CAAE,KAAI,OAAQ,EAAM,MAAO,CACpC,CAEA,GAAY,EAAc,EAAoB,CAE5C,GAAI,KAAKC,GAAY,IAAI,CAAI,EAC3B,KAAKA,GAAY,OAAO,CAAI,OACvB,GAAI,KAAKA,GAAY,MAAQ,GAElC,IAAK,IAAM,KAAU,KAAKA,GAAY,KAAK,EAAG,CAC5C,KAAKA,GAAY,OAAO,CAAM,EAE9B,KACF,CAGF,KAAKA,GAAY,IAAI,EAAM,CAAK,CAClC,CAEA,GAAmB,EAA4B,CAC7C,IAAM,EAAQ,EAAM,UAAY,KAAKY,GAErC,OACE,GAAS,GACT,EAAA,MACA,EAAM,SAAW,KAAKD,EAE1B,CAEA,IAAqB,CACf,KAAKD,KAAgB,OACvB,aAAa,KAAKA,EAAW,EAC7B,KAAKA,GAAc,MAGrB,KAAKH,GAAiB,IACxB,CAEA,IAAqB,CACf,KAAKQ,KAAgB,OACvB,aAAa,KAAKA,EAAW,EAC7B,KAAKA,GAAc,KAEvB,CAEA,IAAiB,CACf,SAAS,oBACP,YACA,KAAKd,GACL,CACF,EACA,SAAS,oBACP,aACA,KAAKC,GACL,CACF,EACA,SAAS,oBACP,YACA,KAAKC,GACL,CACF,EAEA,KAAKK,GAAa,EAClB,KAAKK,GAAa,EAClB,KAAKF,GAAmB,KACxB,KAAKC,GAAsB,IAC3B,KAAKZ,GAAY,MAAM,CACzB,CACF,ECvSA,SAAgB,EACd,EACe,CACf,IAAM,EAA0C,CAC9C,GAAG,EACH,GAAG,CACL,EAMA,OAJI,CAAC,OAAO,SAAS,EAAQ,KAAK,GAAK,EAAQ,MAAQ,KACrD,EAAQ,MAAQ,GAGX,SAAuB,EAAY,EAAe,CAYvD,OAXI,OAAO,SAAa,IACf,CAAC,EAUH,IAPY,EACjB,GAAA,EAAA,EAAA,cACa,CAAU,EACvB,EACA,CAGU,EAAE,UAAU,CAC1B,CACF"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["#router","#api","#options","#getDependency","#removeExtensions","#removeChangesSubscription","#compiledPreloads","#stateCache","#onTreeChanged","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#invalidateStateCache","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload","#cacheState"],"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 { getRoutesApi } from \"@real-router/core/api\";\n\nimport {\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 TreeChangedEvent,\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\nconst STATE_CACHE_LIMIT = 32;\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 #removeChangesSubscription: () => void;\n readonly #compiledPreloads = new Map<\n string,\n { fn: PreloadFn; factory: PreloadFnFactory }\n >();\n // Pre-resolved State cache keyed by anchor href. Populated when a hover/touch\n // resolves a route via router.matchUrl, consumed once via\n // router.getPreloadedState(href). Single-use semantics (delete-on-read) keep\n // the cache from drifting out of sync with current world state — once the\n // consumer commits the snapshot via api.navigateToState, the entry is gone.\n // Bounded with insertion-order eviction (#562).\n readonly #stateCache = new Map<string, State>();\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 getPreloadedState: (href: string): State | undefined => {\n const state = this.#stateCache.get(href);\n\n if (state) {\n this.#stateCache.delete(href);\n }\n\n return state;\n },\n });\n\n // Drop compiled-preload entries for routes removed from the tree. Without\n // this, `#resolvePreload` can never reach them again (matchUrl returns\n // undefined for a removed route), so the entry would be dead memory until\n // teardown. `add`/`update` need no handling — `#resolvePreload` already\n // revalidates lazily via the cached `factory` reference.\n this.#removeChangesSubscription = getRoutesApi(router).subscribeChanges(\n (event) => {\n this.#onTreeChanged(event);\n },\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.#removeChangesSubscription();\n this.#removeExtensions();\n },\n };\n }\n\n #onTreeChanged(event: TreeChangedEvent): void {\n switch (event.op) {\n case \"remove\": {\n for (const route of event.removedSubtree) {\n this.#compiledPreloads.delete(route.name);\n }\n\n this.#invalidateStateCache();\n\n break;\n }\n case \"replace\": {\n for (const route of event.removed) {\n this.#compiledPreloads.delete(route.name);\n }\n\n this.#invalidateStateCache();\n\n break;\n }\n case \"clear\": {\n this.#compiledPreloads.clear();\n this.#invalidateStateCache();\n\n break;\n }\n // \"add\" / \"update\": lazy factory-reference revalidation in\n // #resolvePreload handles these — no eager cleanup needed.\n }\n }\n\n /**\n * Drops all pre-resolved `State` snapshots. `#stateCache` is keyed by `href`,\n * not route name, so a removed/changed route cannot be pruned selectively —\n * clearing the whole (≤32-entry) cache prevents `getPreloadedState(href)` from\n * handing a consumer a `State` that points at a route no longer in the tree.\n * Entries repopulate on the next hover/touch.\n */\n #invalidateStateCache(): void {\n this.#stateCache.clear();\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 this.#cacheState(anchor.href, state);\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 #cacheState(href: string, state: State): void {\n // Re-insert to refresh recency ordering (Map iteration is insertion order).\n if (this.#stateCache.has(href)) {\n this.#stateCache.delete(href);\n } else if (this.#stateCache.size >= STATE_CACHE_LIMIT) {\n // size >= LIMIT > 0 → iterator has at least one key.\n for (const oldest of this.#stateCache.keys()) {\n this.#stateCache.delete(oldest);\n\n break;\n }\n }\n\n this.#stateCache.set(href, state);\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 this.#stateCache.clear();\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/dist/esm/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{getPluginApi as e}from"@real-router/core/api";const
|
|
1
|
+
import{getPluginApi as e,getRoutesApi as t}from"@real-router/core/api";const n={delay:65,networkAware:!0},r={capture:!0,passive:!0};function i(){let e=navigator.connection;return e?!!(e.saveData||e.effectiveType?.includes(`2g`)):!1}var a=class{#e;#t;#n;#r;#i;#a;#o=new Map;#s=new Map;#c=null;#l=null;#u=null;#d=0;#f=null;#p=NaN;constructor(e,n,r,i){this.#e=e,this.#t=n,this.#n=r,this.#r=i;let a={...r};this.#i=n.extendRouter({getPreloadSettings:()=>a,getPreloadedState:e=>{let t=this.#s.get(e);return t&&this.#s.delete(e),t}}),this.#a=t(e).subscribeChanges(e=>{this.#m(e)})}getPlugin(){return{onStart:()=>{document.addEventListener(`mouseover`,this.#g,r),document.addEventListener(`touchstart`,this.#_,r),document.addEventListener(`touchmove`,this.#v,r)},onStop:()=>{this.#E()},teardown:()=>{this.#E(),this.#a(),this.#i()}}}#m(e){switch(e.op){case`remove`:for(let t of e.removedSubtree)this.#o.delete(t.name);this.#h();break;case`replace`:for(let t of e.removed)this.#o.delete(t.name);this.#h();break;case`clear`:this.#o.clear(),this.#h();break}}#h(){this.#s.clear()}#g=e=>{if(this.#C(e))return;let t=this.#y(e.target);if(t===this.#c)return;this.#w(),this.#c=t;let n=this.#b(t);n&&(this.#l=setTimeout(()=>{this.#l=null,n.fn(n.params).catch(()=>{})},this.#n.delay))};#_=e=>{this.#f=e.target,this.#p=e.timeStamp,this.#T();let t=this.#y(e.target),n=this.#b(t);!n||e.touches.length===0||(this.#d=e.touches[0].clientY,this.#u=setTimeout(()=>{this.#u=null,n.fn(n.params).catch(()=>{})},100))};#v=e=>{this.#u===null||e.touches.length===0||Math.abs(e.touches[0].clientY-this.#d)>10&&this.#T()};#y(e){return e instanceof Element?e.closest(`a[href]`):null}#b(e){if(e&&!(`noPreload`in e.dataset)&&!(this.#n.networkAware&&i()))return this.#x(e)}#x(e){let t=this.#e.matchUrl?.(e.href);if(!t)return;this.#S(e.href,t);let n=this.#t.getRouteConfig(t.name),r=typeof n?.preload==`function`?n.preload:void 0;if(!r){this.#o.delete(t.name);return}let i=this.#o.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.#o.set(t.name,{fn:a,factory:r}),{fn:a,params:t.params}}#S(e,t){if(this.#s.has(e))this.#s.delete(e);else if(this.#s.size>=32)for(let e of this.#s.keys()){this.#s.delete(e);break}this.#s.set(e,t)}#C(e){let t=e.timeStamp-this.#p;return t>=0&&t<2500&&e.target===this.#f}#w(){this.#l!==null&&(clearTimeout(this.#l),this.#l=null),this.#c=null}#T(){this.#u!==null&&(clearTimeout(this.#u),this.#u=null)}#E(){document.removeEventListener(`mouseover`,this.#g,r),document.removeEventListener(`touchstart`,this.#_,r),document.removeEventListener(`touchmove`,this.#v,r),this.#w(),this.#T(),this.#f=null,this.#p=NaN,this.#s.clear()}};function o(t){let r={...n,...t};return(!Number.isFinite(r.delay)||r.delay<0)&&(r.delay=0),function(t,n){return typeof document>`u`?{}:new a(t,e(t),r,n).getPlugin()}}export{o as preloadPluginFactory};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/esm/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["#router","#api","#options","#getDependency","#removeExtensions","#compiledPreloads","#stateCache","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload","#cacheState"],"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\nconst STATE_CACHE_LIMIT = 32;\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 // Pre-resolved State cache keyed by anchor href. Populated when a hover/touch\n // resolves a route via router.matchUrl, consumed once via\n // router.getPreloadedState(href). Single-use semantics (delete-on-read) keep\n // the cache from drifting out of sync with current world state — once the\n // consumer commits the snapshot via api.navigateToState, the entry is gone.\n // Bounded with insertion-order eviction (#562).\n readonly #stateCache = new Map<string, State>();\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 getPreloadedState: (href: string): State | undefined => {\n const state = this.#stateCache.get(href);\n\n if (state) {\n this.#stateCache.delete(href);\n }\n\n return state;\n },\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 this.#cacheState(anchor.href, state);\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 #cacheState(href: string, state: State): void {\n // Re-insert to refresh recency ordering (Map iteration is insertion order).\n if (this.#stateCache.has(href)) {\n this.#stateCache.delete(href);\n } else if (this.#stateCache.size >= STATE_CACHE_LIMIT) {\n // size >= LIMIT > 0 → iterator has at least one key.\n for (const oldest of this.#stateCache.keys()) {\n this.#stateCache.delete(oldest);\n\n break;\n }\n }\n\n this.#stateCache.set(href, state);\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 this.#stateCache.clear();\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
|
+
{"version":3,"file":"index.mjs","names":["#router","#api","#options","#getDependency","#removeExtensions","#removeChangesSubscription","#compiledPreloads","#stateCache","#onTreeChanged","#handleMouseOver","#handleTouchStart","#handleTouchMove","#cleanup","#invalidateStateCache","#isGhostMouseEvent","#findAnchor","#currentAnchor","#cancelHover","#resolveAnchorPreload","#hoverTimer","#lastTouchTarget","#lastTouchTimeStamp","#cancelTouch","#touchStartY","#touchTimer","#resolvePreload","#cacheState"],"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 { getRoutesApi } from \"@real-router/core/api\";\n\nimport {\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 TreeChangedEvent,\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\nconst STATE_CACHE_LIMIT = 32;\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 #removeChangesSubscription: () => void;\n readonly #compiledPreloads = new Map<\n string,\n { fn: PreloadFn; factory: PreloadFnFactory }\n >();\n // Pre-resolved State cache keyed by anchor href. Populated when a hover/touch\n // resolves a route via router.matchUrl, consumed once via\n // router.getPreloadedState(href). Single-use semantics (delete-on-read) keep\n // the cache from drifting out of sync with current world state — once the\n // consumer commits the snapshot via api.navigateToState, the entry is gone.\n // Bounded with insertion-order eviction (#562).\n readonly #stateCache = new Map<string, State>();\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 getPreloadedState: (href: string): State | undefined => {\n const state = this.#stateCache.get(href);\n\n if (state) {\n this.#stateCache.delete(href);\n }\n\n return state;\n },\n });\n\n // Drop compiled-preload entries for routes removed from the tree. Without\n // this, `#resolvePreload` can never reach them again (matchUrl returns\n // undefined for a removed route), so the entry would be dead memory until\n // teardown. `add`/`update` need no handling — `#resolvePreload` already\n // revalidates lazily via the cached `factory` reference.\n this.#removeChangesSubscription = getRoutesApi(router).subscribeChanges(\n (event) => {\n this.#onTreeChanged(event);\n },\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.#removeChangesSubscription();\n this.#removeExtensions();\n },\n };\n }\n\n #onTreeChanged(event: TreeChangedEvent): void {\n switch (event.op) {\n case \"remove\": {\n for (const route of event.removedSubtree) {\n this.#compiledPreloads.delete(route.name);\n }\n\n this.#invalidateStateCache();\n\n break;\n }\n case \"replace\": {\n for (const route of event.removed) {\n this.#compiledPreloads.delete(route.name);\n }\n\n this.#invalidateStateCache();\n\n break;\n }\n case \"clear\": {\n this.#compiledPreloads.clear();\n this.#invalidateStateCache();\n\n break;\n }\n // \"add\" / \"update\": lazy factory-reference revalidation in\n // #resolvePreload handles these — no eager cleanup needed.\n }\n }\n\n /**\n * Drops all pre-resolved `State` snapshots. `#stateCache` is keyed by `href`,\n * not route name, so a removed/changed route cannot be pruned selectively —\n * clearing the whole (≤32-entry) cache prevents `getPreloadedState(href)` from\n * handing a consumer a `State` that points at a route no longer in the tree.\n * Entries repopulate on the next hover/touch.\n */\n #invalidateStateCache(): void {\n this.#stateCache.clear();\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 this.#cacheState(anchor.href, state);\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 #cacheState(href: string, state: State): void {\n // Re-insert to refresh recency ordering (Map iteration is insertion order).\n if (this.#stateCache.has(href)) {\n this.#stateCache.delete(href);\n } else if (this.#stateCache.size >= STATE_CACHE_LIMIT) {\n // size >= LIMIT > 0 → iterator has at least one key.\n for (const oldest of this.#stateCache.keys()) {\n this.#stateCache.delete(oldest);\n\n break;\n }\n }\n\n this.#stateCache.set(href, state);\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 this.#stateCache.clear();\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":"uEAEA,MAAa,EAAiD,CAC5D,MAAO,GACP,aAAc,EAChB,EAQa,EAA4C,CACvD,QAAS,GACT,QAAS,EACX,ECRA,SAAgB,GAA4B,CAC1C,IAAM,EAAc,UAAsC,WAY1D,OAVK,EAML,GAHI,EAAW,UAGX,EAAW,eAAe,SAAS,IAAI,GALlC,EAUX,CCWA,IAAa,EAAb,KAA2B,CACzB,GACA,GACA,GACA,GACA,GACA,GACA,GAA6B,IAAI,IAUjC,GAAuB,IAAI,IAE3B,GAA2C,KAC3C,GAAoD,KACpD,GAAoD,KACpD,GAAe,EACf,GAAuC,KACvC,GAAsB,IAEtB,YACE,EACA,EACA,EACA,EACA,CACA,KAAKA,GAAU,EACf,KAAKC,GAAO,EACZ,KAAKC,GAAW,EAChB,KAAKC,GAAiB,EAEtB,IAAM,EAAgB,CAAE,GAAG,CAAQ,EAEnC,KAAKC,GAAoB,EAAI,aAAa,CACxC,uBAA0B,EAC1B,kBAAoB,GAAoC,CACtD,IAAM,EAAQ,KAAKG,GAAY,IAAI,CAAI,EAMvC,OAJI,GACF,KAAKA,GAAY,OAAO,CAAI,EAGvB,CACT,CACF,CAAC,EAOD,KAAKF,GAA6B,EAAa,CAAM,CAAC,CAAC,iBACpD,GAAU,CACT,KAAKG,GAAe,CAAK,CAC3B,CACF,CACF,CAEA,WAAoB,CAClB,MAAO,CACL,YAAe,CACb,SAAS,iBACP,YACA,KAAKC,GACL,CACF,EACA,SAAS,iBACP,aACA,KAAKC,GACL,CACF,EACA,SAAS,iBACP,YACA,KAAKC,GACL,CACF,CACF,EAEA,WAAc,CACZ,KAAKC,GAAS,CAChB,EAEA,aAAgB,CACd,KAAKA,GAAS,EACd,KAAKP,GAA2B,EAChC,KAAKD,GAAkB,CACzB,CACF,CACF,CAEA,GAAe,EAA+B,CAC5C,OAAQ,EAAM,GAAd,CACE,IAAK,SACH,IAAK,IAAM,KAAS,EAAM,eACxB,KAAKE,GAAkB,OAAO,EAAM,IAAI,EAG1C,KAAKO,GAAsB,EAE3B,MAEF,IAAK,UACH,IAAK,IAAM,KAAS,EAAM,QACxB,KAAKP,GAAkB,OAAO,EAAM,IAAI,EAG1C,KAAKO,GAAsB,EAE3B,MAEF,IAAK,QACH,KAAKP,GAAkB,MAAM,EAC7B,KAAKO,GAAsB,EAE3B,KAIJ,CACF,CASA,IAA8B,CAC5B,KAAKN,GAAY,MAAM,CACzB,CAEA,GAA6B,GAA4B,CACvD,GAAI,KAAKO,GAAmB,CAAK,EAC/B,OAGF,IAAM,EAAS,KAAKC,GAAY,EAAM,MAAM,EAE5C,GAAI,IAAW,KAAKC,GAClB,OAGF,KAAKC,GAAa,EAClB,KAAKD,GAAiB,EAEtB,IAAM,EAAU,KAAKE,GAAsB,CAAM,EAE5C,IAIL,KAAKC,GAAc,eAAiB,CAClC,KAAKA,GAAc,KACnB,EAAQ,GAAG,EAAQ,MAAM,CAAC,CAAC,UAAY,CAAC,CAAC,CAC3C,EAAG,KAAKjB,GAAS,KAAK,EACxB,EAEA,GAA8B,GAA4B,CACxD,KAAKkB,GAAmB,EAAM,OAC9B,KAAKC,GAAsB,EAAM,UAEjC,KAAKC,GAAa,EAElB,IAAM,EAAS,KAAKP,GAAY,EAAM,MAAM,EACtC,EAAU,KAAKG,GAAsB,CAAM,EAE7C,CAAC,GAAW,EAAM,QAAQ,SAAW,IAIzC,KAAKK,GAAe,EAAM,QAAQ,EAAE,CAAC,QAErC,KAAKC,GAAc,eAAiB,CAClC,KAAKA,GAAc,KACnB,EAAQ,GAAG,EAAQ,MAAM,CAAC,CAAC,UAAY,CAAC,CAAC,CAC3C,EAAA,GAAsB,EACxB,EAEA,GAA6B,GAA4B,CACnD,KAAKA,KAAgB,MAAQ,EAAM,QAAQ,SAAW,GAI3C,KAAK,IAAI,EAAM,QAAQ,EAAE,CAAC,QAAU,KAAKD,EAE/C,EAAA,IACP,KAAKD,GAAa,CAEtB,EAEA,GAAY,EAAsD,CAChE,OAAO,aAAkB,QACrB,EAAO,QAA2B,SAAS,EAC3C,IACN,CAEA,GACE,EAC+C,CAC1C,MAID,gBAAe,EAAO,UAItB,OAAKpB,GAAS,cAAgB,EAAiB,GAInD,OAAO,KAAKuB,GAAgB,CAAM,CACpC,CAEA,GACE,EAC+C,CAC/C,IAAM,EAAQ,KAAKzB,GAAQ,WAAW,EAAO,IAAI,EAEjD,GAAI,CAAC,EACH,OAGF,KAAK0B,GAAY,EAAO,KAAM,CAAK,EAEnC,IAAM,EAAS,KAAKzB,GAAK,eAAe,EAAM,IAAI,EAC5C,EACJ,OAAO,GAAQ,SAAY,WACtB,EAAO,QACR,IAAA,GAEN,GAAI,CAAC,EAAS,CACZ,KAAKK,GAAkB,OAAO,EAAM,IAAI,EAExC,MACF,CAEA,IAAM,EAAS,KAAKA,GAAkB,IAAI,EAAM,IAAI,EAEpD,GAAI,GAAQ,UAAY,EACtB,MAAO,CAAE,GAAI,EAAO,GAAI,OAAQ,EAAM,MAAO,EAG/C,IAAI,EAEJ,GAAI,CACF,EAAK,EAAQ,KAAKN,GAAS,KAAKG,EAAc,CAChD,MAAQ,CACN,MACF,CAIA,OAFA,KAAKG,GAAkB,IAAI,EAAM,KAAM,CAAE,KAAI,SAAQ,CAAC,EAE/C,CAAE,KAAI,OAAQ,EAAM,MAAO,CACpC,CAEA,GAAY,EAAc,EAAoB,CAE5C,GAAI,KAAKC,GAAY,IAAI,CAAI,EAC3B,KAAKA,GAAY,OAAO,CAAI,OACvB,GAAI,KAAKA,GAAY,MAAQ,GAElC,IAAK,IAAM,KAAU,KAAKA,GAAY,KAAK,EAAG,CAC5C,KAAKA,GAAY,OAAO,CAAM,EAE9B,KACF,CAGF,KAAKA,GAAY,IAAI,EAAM,CAAK,CAClC,CAEA,GAAmB,EAA4B,CAC7C,IAAM,EAAQ,EAAM,UAAY,KAAKc,GAErC,OACE,GAAS,GACT,EAAA,MACA,EAAM,SAAW,KAAKD,EAE1B,CAEA,IAAqB,CACf,KAAKD,KAAgB,OACvB,aAAa,KAAKA,EAAW,EAC7B,KAAKA,GAAc,MAGrB,KAAKH,GAAiB,IACxB,CAEA,IAAqB,CACf,KAAKQ,KAAgB,OACvB,aAAa,KAAKA,EAAW,EAC7B,KAAKA,GAAc,KAEvB,CAEA,IAAiB,CACf,SAAS,oBACP,YACA,KAAKf,GACL,CACF,EACA,SAAS,oBACP,aACA,KAAKC,GACL,CACF,EACA,SAAS,oBACP,YACA,KAAKC,GACL,CACF,EAEA,KAAKM,GAAa,EAClB,KAAKK,GAAa,EAClB,KAAKF,GAAmB,KACxB,KAAKC,GAAsB,IAC3B,KAAKd,GAAY,MAAM,CACzB,CACF,ECjWA,SAAgB,EACd,EACe,CACf,IAAM,EAA0C,CAC9C,GAAG,EACH,GAAG,CACL,EAMA,OAJI,CAAC,OAAO,SAAS,EAAQ,KAAK,GAAK,EAAQ,MAAQ,KACrD,EAAQ,MAAQ,GAGX,SAAuB,EAAY,EAAe,CAYvD,OAXI,OAAO,SAAa,IACf,CAAC,EAUH,IAPY,EACjB,EACA,EAAa,CAAU,EACvB,EACA,CAGU,CAAC,CAAC,UAAU,CAC1B,CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/preload-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
22
|
-
"src"
|
|
21
|
+
"dist"
|
|
23
22
|
],
|
|
24
23
|
"repository": {
|
|
25
24
|
"type": "git",
|
|
@@ -45,11 +44,11 @@
|
|
|
45
44
|
},
|
|
46
45
|
"sideEffects": false,
|
|
47
46
|
"dependencies": {
|
|
48
|
-
"@real-router/core": "^0.
|
|
49
|
-
"@real-router/types": "^0.
|
|
47
|
+
"@real-router/core": "^0.56.0",
|
|
48
|
+
"@real-router/types": "^0.36.0"
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
52
|
-
"jsdom": "
|
|
51
|
+
"jsdom": "29.1.1"
|
|
53
52
|
},
|
|
54
53
|
"scripts": {
|
|
55
54
|
"test": "vitest",
|
package/src/constants.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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;
|
|
13
|
-
|
|
14
|
-
export const LISTENER_OPTIONS: AddEventListenerOptions = {
|
|
15
|
-
capture: true,
|
|
16
|
-
passive: true,
|
|
17
|
-
};
|
package/src/factory.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
if (!Number.isFinite(options.delay) || options.delay < 0) {
|
|
18
|
-
options.delay = 0;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return function preloadPlugin(routerBase, getDependency) {
|
|
22
|
-
if (typeof document === "undefined") {
|
|
23
|
-
return {};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const plugin = new PreloadPlugin(
|
|
27
|
-
routerBase as Router,
|
|
28
|
-
getPluginApi(routerBase),
|
|
29
|
-
options,
|
|
30
|
-
getDependency,
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
return plugin.getPlugin();
|
|
34
|
-
};
|
|
35
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/method-signature-style -- method syntax required for declaration merging overload (property syntax causes TS2717) */
|
|
2
|
-
import type { PreloadFnFactory, PreloadPluginOptions } from "./types";
|
|
3
|
-
import type { DefaultDependencies, State } from "@real-router/core";
|
|
4
|
-
|
|
5
|
-
export { preloadPluginFactory } from "./factory";
|
|
6
|
-
|
|
7
|
-
export type {
|
|
8
|
-
PreloadPluginOptions,
|
|
9
|
-
PreloadFn,
|
|
10
|
-
PreloadFnFactory,
|
|
11
|
-
} from "./types";
|
|
12
|
-
|
|
13
|
-
declare module "@real-router/core" {
|
|
14
|
-
interface Route<Dependencies extends DefaultDependencies> {
|
|
15
|
-
preload?: PreloadFnFactory<Dependencies>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface Router {
|
|
19
|
-
getPreloadedState?: (href: string) => State | undefined;
|
|
20
|
-
getPreloadSettings(): PreloadPluginOptions;
|
|
21
|
-
}
|
|
22
|
-
}
|
package/src/network.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
GHOST_EVENT_THRESHOLD,
|
|
3
|
-
LISTENER_OPTIONS,
|
|
4
|
-
TOUCH_PRELOAD_DELAY,
|
|
5
|
-
TOUCH_SCROLL_THRESHOLD,
|
|
6
|
-
} from "./constants";
|
|
7
|
-
import { isSlowConnection } from "./network";
|
|
8
|
-
|
|
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";
|
|
21
|
-
import type { PluginApi } from "@real-router/core/api";
|
|
22
|
-
|
|
23
|
-
declare module "@real-router/core" {
|
|
24
|
-
interface Router {
|
|
25
|
-
matchUrl?: (url: string) => State | undefined;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const STATE_CACHE_LIMIT = 32;
|
|
30
|
-
|
|
31
|
-
export class PreloadPlugin {
|
|
32
|
-
readonly #router: Router;
|
|
33
|
-
readonly #api: PluginApi;
|
|
34
|
-
readonly #options: Required<PreloadPluginOptions>;
|
|
35
|
-
readonly #getDependency: Parameters<PluginFactory>[1];
|
|
36
|
-
readonly #removeExtensions: () => void;
|
|
37
|
-
readonly #compiledPreloads = new Map<
|
|
38
|
-
string,
|
|
39
|
-
{ fn: PreloadFn; factory: PreloadFnFactory }
|
|
40
|
-
>();
|
|
41
|
-
// Pre-resolved State cache keyed by anchor href. Populated when a hover/touch
|
|
42
|
-
// resolves a route via router.matchUrl, consumed once via
|
|
43
|
-
// router.getPreloadedState(href). Single-use semantics (delete-on-read) keep
|
|
44
|
-
// the cache from drifting out of sync with current world state — once the
|
|
45
|
-
// consumer commits the snapshot via api.navigateToState, the entry is gone.
|
|
46
|
-
// Bounded with insertion-order eviction (#562).
|
|
47
|
-
readonly #stateCache = new Map<string, State>();
|
|
48
|
-
|
|
49
|
-
#currentAnchor: HTMLAnchorElement | null = null;
|
|
50
|
-
#hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
-
#touchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
52
|
-
#touchStartY = 0;
|
|
53
|
-
#lastTouchTarget: EventTarget | null = null;
|
|
54
|
-
#lastTouchTimeStamp = Number.NaN;
|
|
55
|
-
|
|
56
|
-
constructor(
|
|
57
|
-
router: Router,
|
|
58
|
-
api: PluginApi,
|
|
59
|
-
options: Required<PreloadPluginOptions>,
|
|
60
|
-
getDependency: Parameters<PluginFactory>[1],
|
|
61
|
-
) {
|
|
62
|
-
this.#router = router;
|
|
63
|
-
this.#api = api;
|
|
64
|
-
this.#options = options;
|
|
65
|
-
this.#getDependency = getDependency;
|
|
66
|
-
|
|
67
|
-
const cachedOptions = { ...options };
|
|
68
|
-
|
|
69
|
-
this.#removeExtensions = api.extendRouter({
|
|
70
|
-
getPreloadSettings: () => cachedOptions,
|
|
71
|
-
getPreloadedState: (href: string): State | undefined => {
|
|
72
|
-
const state = this.#stateCache.get(href);
|
|
73
|
-
|
|
74
|
-
if (state) {
|
|
75
|
-
this.#stateCache.delete(href);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return state;
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
getPlugin(): Plugin {
|
|
84
|
-
return {
|
|
85
|
-
onStart: () => {
|
|
86
|
-
document.addEventListener(
|
|
87
|
-
"mouseover",
|
|
88
|
-
this.#handleMouseOver,
|
|
89
|
-
LISTENER_OPTIONS,
|
|
90
|
-
);
|
|
91
|
-
document.addEventListener(
|
|
92
|
-
"touchstart",
|
|
93
|
-
this.#handleTouchStart,
|
|
94
|
-
LISTENER_OPTIONS,
|
|
95
|
-
);
|
|
96
|
-
document.addEventListener(
|
|
97
|
-
"touchmove",
|
|
98
|
-
this.#handleTouchMove,
|
|
99
|
-
LISTENER_OPTIONS,
|
|
100
|
-
);
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
onStop: () => {
|
|
104
|
-
this.#cleanup();
|
|
105
|
-
},
|
|
106
|
-
|
|
107
|
-
teardown: () => {
|
|
108
|
-
this.#cleanup();
|
|
109
|
-
this.#removeExtensions();
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
readonly #handleMouseOver = (event: MouseEvent): void => {
|
|
115
|
-
if (this.#isGhostMouseEvent(event)) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const anchor = this.#findAnchor(event.target);
|
|
120
|
-
|
|
121
|
-
if (anchor === this.#currentAnchor) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
this.#cancelHover();
|
|
126
|
-
this.#currentAnchor = anchor;
|
|
127
|
-
|
|
128
|
-
const preload = this.#resolveAnchorPreload(anchor);
|
|
129
|
-
|
|
130
|
-
if (!preload) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
this.#hoverTimer = setTimeout(() => {
|
|
135
|
-
this.#hoverTimer = null;
|
|
136
|
-
preload.fn(preload.params).catch(() => {});
|
|
137
|
-
}, this.#options.delay);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
readonly #handleTouchStart = (event: TouchEvent): void => {
|
|
141
|
-
this.#lastTouchTarget = event.target;
|
|
142
|
-
this.#lastTouchTimeStamp = event.timeStamp;
|
|
143
|
-
|
|
144
|
-
this.#cancelTouch();
|
|
145
|
-
|
|
146
|
-
const anchor = this.#findAnchor(event.target);
|
|
147
|
-
const preload = this.#resolveAnchorPreload(anchor);
|
|
148
|
-
|
|
149
|
-
if (!preload || event.touches.length === 0) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
this.#touchStartY = event.touches[0].clientY;
|
|
154
|
-
|
|
155
|
-
this.#touchTimer = setTimeout(() => {
|
|
156
|
-
this.#touchTimer = null;
|
|
157
|
-
preload.fn(preload.params).catch(() => {});
|
|
158
|
-
}, TOUCH_PRELOAD_DELAY);
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
readonly #handleTouchMove = (event: TouchEvent): void => {
|
|
162
|
-
if (this.#touchTimer === null || event.touches.length === 0) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const deltaY = Math.abs(event.touches[0].clientY - this.#touchStartY);
|
|
167
|
-
|
|
168
|
-
if (deltaY > TOUCH_SCROLL_THRESHOLD) {
|
|
169
|
-
this.#cancelTouch();
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
#findAnchor(target: EventTarget | null): HTMLAnchorElement | null {
|
|
174
|
-
return target instanceof Element
|
|
175
|
-
? target.closest<HTMLAnchorElement>("a[href]")
|
|
176
|
-
: null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
#resolveAnchorPreload(
|
|
180
|
-
anchor: HTMLAnchorElement | null | undefined,
|
|
181
|
-
): { fn: PreloadFn; params: Params } | undefined {
|
|
182
|
-
if (!anchor) {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if ("noPreload" in anchor.dataset) {
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (this.#options.networkAware && isSlowConnection()) {
|
|
191
|
-
return undefined;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return this.#resolvePreload(anchor);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
#resolvePreload(
|
|
198
|
-
anchor: HTMLAnchorElement,
|
|
199
|
-
): { fn: PreloadFn; params: Params } | undefined {
|
|
200
|
-
const state = this.#router.matchUrl?.(anchor.href);
|
|
201
|
-
|
|
202
|
-
if (!state) {
|
|
203
|
-
return undefined;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
this.#cacheState(anchor.href, state);
|
|
207
|
-
|
|
208
|
-
const config = this.#api.getRouteConfig(state.name);
|
|
209
|
-
const factory =
|
|
210
|
-
typeof config?.preload === "function"
|
|
211
|
-
? (config.preload as PreloadFnFactory)
|
|
212
|
-
: undefined;
|
|
213
|
-
|
|
214
|
-
if (!factory) {
|
|
215
|
-
this.#compiledPreloads.delete(state.name);
|
|
216
|
-
|
|
217
|
-
return undefined;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const cached = this.#compiledPreloads.get(state.name);
|
|
221
|
-
|
|
222
|
-
if (cached?.factory === factory) {
|
|
223
|
-
return { fn: cached.fn, params: state.params };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let fn: PreloadFn;
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
fn = factory(this.#router, this.#getDependency);
|
|
230
|
-
} catch {
|
|
231
|
-
return undefined;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
this.#compiledPreloads.set(state.name, { fn, factory });
|
|
235
|
-
|
|
236
|
-
return { fn, params: state.params };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
#cacheState(href: string, state: State): void {
|
|
240
|
-
// Re-insert to refresh recency ordering (Map iteration is insertion order).
|
|
241
|
-
if (this.#stateCache.has(href)) {
|
|
242
|
-
this.#stateCache.delete(href);
|
|
243
|
-
} else if (this.#stateCache.size >= STATE_CACHE_LIMIT) {
|
|
244
|
-
// size >= LIMIT > 0 → iterator has at least one key.
|
|
245
|
-
for (const oldest of this.#stateCache.keys()) {
|
|
246
|
-
this.#stateCache.delete(oldest);
|
|
247
|
-
|
|
248
|
-
break;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
this.#stateCache.set(href, state);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
#isGhostMouseEvent(event: MouseEvent): boolean {
|
|
256
|
-
const delta = event.timeStamp - this.#lastTouchTimeStamp;
|
|
257
|
-
|
|
258
|
-
return (
|
|
259
|
-
delta >= 0 &&
|
|
260
|
-
delta < GHOST_EVENT_THRESHOLD &&
|
|
261
|
-
event.target === this.#lastTouchTarget
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
#cancelHover(): void {
|
|
266
|
-
if (this.#hoverTimer !== null) {
|
|
267
|
-
clearTimeout(this.#hoverTimer);
|
|
268
|
-
this.#hoverTimer = null;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
this.#currentAnchor = null;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
#cancelTouch(): void {
|
|
275
|
-
if (this.#touchTimer !== null) {
|
|
276
|
-
clearTimeout(this.#touchTimer);
|
|
277
|
-
this.#touchTimer = null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
#cleanup(): void {
|
|
282
|
-
document.removeEventListener(
|
|
283
|
-
"mouseover",
|
|
284
|
-
this.#handleMouseOver,
|
|
285
|
-
LISTENER_OPTIONS,
|
|
286
|
-
);
|
|
287
|
-
document.removeEventListener(
|
|
288
|
-
"touchstart",
|
|
289
|
-
this.#handleTouchStart,
|
|
290
|
-
LISTENER_OPTIONS,
|
|
291
|
-
);
|
|
292
|
-
document.removeEventListener(
|
|
293
|
-
"touchmove",
|
|
294
|
-
this.#handleTouchMove,
|
|
295
|
-
LISTENER_OPTIONS,
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
this.#cancelHover();
|
|
299
|
-
this.#cancelTouch();
|
|
300
|
-
this.#lastTouchTarget = null;
|
|
301
|
-
this.#lastTouchTimeStamp = Number.NaN;
|
|
302
|
-
this.#stateCache.clear();
|
|
303
|
-
}
|
|
304
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { DefaultDependencies, Params, Router } from "@real-router/types";
|
|
2
|
-
|
|
3
|
-
export interface PreloadPluginOptions {
|
|
4
|
-
/** Hover debounce delay in ms. @default 65 */
|
|
5
|
-
delay?: number;
|
|
6
|
-
/** Check saveData/2g and disable preloading on slow connections. @default true */
|
|
7
|
-
networkAware?: boolean;
|
|
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;
|