feat: add hover-intent prefetch for Open Deck button (WEB_PREFETCH=1) (#68)

This commit is contained in:
mwisnowski 2026-04-01 20:54:51 -07:00 committed by GitHub
parent 1f01d8b493
commit e8b8fab3f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 138 additions and 5 deletions

View file

@ -234,6 +234,7 @@ RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350)
USER_THEME_LIMIT = _as_int(os.getenv("USER_THEME_LIMIT"), 8)
ENABLE_PREFETCH = _as_bool(os.getenv("WEB_PREFETCH"), False)
_THEME_MODE_ENV = (os.getenv("THEME_MATCH_MODE") or "").strip().lower()
DEFAULT_THEME_MATCH_MODE = "strict" if _THEME_MODE_ENV in {"strict", "s"} else "permissive"
@ -364,6 +365,7 @@ templates.env.globals.update({
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
"user_theme_limit": USER_THEME_LIMIT,
"default_theme_match_mode": DEFAULT_THEME_MATCH_MODE,
"prefetch_enabled": ENABLE_PREFETCH,
})
# Expose catalog hash (for cache versioning / service worker) best-effort, fallback to 'dev'

View file

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
from ..app import templates
from ..services.orchestrator import tags_for_commander
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
from ..app import ENABLE_BUDGET_MODE
from ..app import ENABLE_BUDGET_MODE, ENABLE_PREFETCH
router = APIRouter(prefix="/decks")
@ -445,7 +445,10 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
except Exception:
pass
return templates.TemplateResponse("decks/view.html", ctx)
resp = templates.TemplateResponse("decks/view.html", ctx)
if ENABLE_PREFETCH:
resp.headers["Cache-Control"] = "private, max-age=30, must-revalidate"
return resp
@router.get("/compare", response_class=HTMLResponse)

View file

@ -0,0 +1,118 @@
/**
* prefetch-hover.ts Hover-intent prefetch/prerender for key navigation targets.
*
* Enabled server-side via WEB_PREFETCH=1. Elements opt in with:
* data-prefetch="1" enable prefetch on this element
* data-prefetch-url="<url>" URL to prefetch (falls back to el.href)
* data-prerender-ok="1" allow Chrome Speculation Rules prerender
* (only safe GET routes with no side effects)
*
* Strategy selection (per element):
* data-prerender-ok="1" + Chrome Speculation Rules support prerender
* otherwise rel=prefetch
*
* Progressive enhancement: degrades gracefully when unsupported.
* Respects navigator.connection.saveData and slow (2G) effective connections.
*/
(function () {
'use strict';
const MAX_CONCURRENT = 2;
const MAX_PRERENDERS = 2;
const DELAY_MS = 100;
let _inflight = 0;
const _prefetched: Record<string, boolean> = {};
// Speculation Rules API detection (Chrome 108+)
const _supportsSpeculation: boolean = (function () {
try {
return typeof HTMLScriptElement !== 'undefined' &&
'supports' in HTMLScriptElement &&
typeof (HTMLScriptElement as any).supports === 'function' &&
(HTMLScriptElement as any).supports('speculationrules');
} catch (_) { return false; }
})();
let _speculationEl: HTMLScriptElement | null = null;
const _prerenderQueued: string[] = [];
function _saverMode(): boolean {
try {
const conn: any = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection || {};
if (conn.saveData === true) return true;
const et: string = conn.effectiveType || '';
return et === '2g' || et === 'slow-2g';
} catch (_) { return false; }
}
/** Inject/update a single <script type="speculationrules"> for prerender. */
function _addSpeculationPrerender(url: string): void {
if (_prerenderQueued.indexOf(url) !== -1) return;
// Cap queued prerenders to avoid excess memory
if (_prerenderQueued.length >= MAX_PRERENDERS) {
_prerenderQueued.shift();
}
_prerenderQueued.push(url);
const rules = { prerender: [{ source: 'list', urls: _prerenderQueued.slice(), eagerness: 'immediate' }] };
if (!_speculationEl) {
_speculationEl = document.createElement('script');
_speculationEl.type = 'speculationrules';
document.head.appendChild(_speculationEl);
}
_speculationEl.textContent = JSON.stringify(rules);
}
function _injectPrefetch(url: string): void {
if (_prefetched[url]) return;
if (_inflight >= MAX_CONCURRENT) return;
_prefetched[url] = true;
_inflight++;
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.as = 'document';
link.addEventListener('load', function () { if (_inflight > 0) _inflight--; });
link.addEventListener('error', function () { if (_inflight > 0) _inflight--; });
document.head.appendChild(link);
}
const _wired: WeakSet<Element> = new WeakSet();
function _attach(el: Element): void {
if (_wired.has(el)) return;
_wired.add(el);
let timer: ReturnType<typeof setTimeout> | null = null;
el.addEventListener('mouseenter', function () {
if (_saverMode()) return;
const url = (el as HTMLElement).getAttribute('data-prefetch-url') || (el as HTMLAnchorElement).href || '';
if (!url) return;
const prerenderOk = (el as HTMLElement).getAttribute('data-prerender-ok') === '1';
timer = setTimeout(function () {
timer = null;
if (_saverMode()) return;
if (_supportsSpeculation && prerenderOk) {
_addSpeculationPrerender(url);
} else {
if (_inflight >= MAX_CONCURRENT) return;
_injectPrefetch(url);
}
}, DELAY_MS);
});
el.addEventListener('mouseleave', function () {
if (timer !== null) { clearTimeout(timer); timer = null; }
});
}
function _init(): void {
document.querySelectorAll('[data-prefetch="1"]').forEach(_attach);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
// Re-scan after HTMX partial updates so dynamically-added elements are wired
document.addEventListener('htmx:afterSettle', _init);
})();

View file

@ -399,6 +399,9 @@
{% endif %}
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
<!-- Hover card panel system moved to cardHover.ts -->
{% if prefetch_enabled %}
<script src="/static/js/prefetch-hover.js?v=20260401-1"></script>
{% endif %}
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
<script>
(function(){

View file

@ -76,7 +76,7 @@
{% endif %}
<form action="/decks/view" method="get" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
<button type="submit" aria-label="Open deck {{ it.commander }}"{% if prefetch_enabled %} data-prefetch="1" data-prefetch-url="/decks/view?name={{ it.name|urlencode }}"{% endif %}>Open</button>
</form>
</div>
</div>