From 516dc51d35629f4f39d5f1ac826df092e9b9df00 Mon Sep 17 00:00:00 2001 From: Nicholas Haley Date: Wed, 12 Aug 2026 23:09:21 -0400 Subject: [PATCH] fix: Fix region pill and slow tiles on cold start Persist the region manifest to disk and return it immediately on launch so the coverage pill resolves non-downloaded areas without waiting on R2. Prewarm the world PMTiles archive and cache generated basemap styles to reduce first-tile latency when the app hasn't been opened recently. --- apps/dashboard/src/main/index.ts | 2 + apps/dashboard/src/main/region-packs.ts | 84 +++++++++++++++++-- apps/dashboard/src/main/region-protocol.ts | 28 ++++++- apps/dashboard/src/preload/index.d.ts | 1 + apps/dashboard/src/preload/index.ts | 7 ++ .../map/region-coverage-indicator.tsx | 11 ++- .../renderer/src/hooks/use-region-packs.ts | 4 + 7 files changed, 128 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/src/main/index.ts b/apps/dashboard/src/main/index.ts index e99623e6..5f6146de 100644 --- a/apps/dashboard/src/main/index.ts +++ b/apps/dashboard/src/main/index.ts @@ -26,6 +26,7 @@ import { registerMcpIpc } from "./mcp-ipc"; import { mcpManager } from "./mcp/manager"; import { registerRegionPacksIpc } from "./region-packs"; import { + prewarmWorldArchive, registerAssetProtocol, registerLocalSchemes, registerRegionProtocol @@ -184,6 +185,7 @@ app.whenReady().then(() => { // global glyphs/sprites + world basemap bundled with the app. registerRegionProtocol(join(appStateDir, "regions"), worldPmtilesPath()); registerAssetProtocol(basemapAssetsDir()); + void prewarmWorldArchive(worldPmtilesPath()); // Reads `vaultRoot` per-request so vault switches/renames need no re-registration. registerVaultProtocol(() => vaultRoot); registerWikiIpc(() => vaultRoot); diff --git a/apps/dashboard/src/main/region-packs.ts b/apps/dashboard/src/main/region-packs.ts index 7d25afd5..c6aee9ab 100644 --- a/apps/dashboard/src/main/region-packs.ts +++ b/apps/dashboard/src/main/region-packs.ts @@ -35,10 +35,14 @@ const R2_BASE = ( // Completeness marker written last after a successful download, so an interrupted // download (whose .part files are cleaned up) never leaves a dir that looks installed. const PACK_META_FILENAME = ".pack.json"; +const MANIFEST_CACHE_FILENAME = "manifest-cache.json"; const MANIFEST_TTL_MS = 60_000; const PROGRESS_THROTTLE_MS = 200; let manifestCache: { at: number; data: RegionManifest } | null = null; +/** Set by {@link registerRegionPacksIpc} — disk cache lives in userData. */ +let manifestAppStateDir: string | null = null; +let manifestRefresh: Promise | null = null; /** Active downloads keyed by region slug, so cancelDownload can abort them. */ const activeDownloads = new Map(); @@ -68,14 +72,37 @@ function broadcastChanged(): void { sendToRenderer("regions:changed"); } -/** - * Fetch the region catalog from R2. Cached briefly so opening the Offline tab and - * re-rendering doesn't re-hit the network on every keystroke. `force` bypasses it. - */ -export async function fetchManifest(force = false): Promise { - if (!force && manifestCache && Date.now() - manifestCache.at < MANIFEST_TTL_MS) { - return manifestCache.data; +function broadcastManifestUpdated(data: RegionManifest): void { + sendToRenderer("regions:manifest-updated", data); +} + +function manifestDiskPath(): string { + if (!manifestAppStateDir) throw new Error("Region manifest disk cache is not initialized."); + return join(manifestAppStateDir, MANIFEST_CACHE_FILENAME); +} + +function readManifestFromDisk(): RegionManifest | null { + if (!manifestAppStateDir) return null; + try { + const data = JSON.parse(readFileSync(manifestDiskPath(), "utf-8")) as RegionManifest; + if (!data || typeof data !== "object" || typeof data.regions !== "object") return null; + return data; + } catch { + return null; } +} + +function writeManifestToDisk(data: RegionManifest): void { + if (!manifestAppStateDir) return; + writeFileSync(manifestDiskPath(), JSON.stringify(data)); +} + +function seedManifestCacheFromDisk(): void { + const disk = readManifestFromDisk(); + if (disk) manifestCache = { at: 0, data: disk }; +} + +async function fetchManifestFromNetwork(): Promise { const res = await fetch(`${R2_BASE}/manifest.json`, { cache: "no-store" }); if (!res.ok) throw new Error(`Failed to fetch region manifest: HTTP ${res.status}`); const data = (await res.json()) as RegionManifest; @@ -83,9 +110,48 @@ export async function fetchManifest(force = false): Promise { throw new Error("Region manifest is malformed."); } manifestCache = { at: Date.now(), data }; + writeManifestToDisk(data); + broadcastManifestUpdated(data); return data; } +function refreshManifestInBackground(): void { + if (manifestRefresh) return; + manifestRefresh = fetchManifestFromNetwork().finally(() => { + manifestRefresh = null; + }); + void manifestRefresh.catch(() => {}); +} + +/** + * Fetch the region catalog from R2. Cached briefly in memory and persistently on + * disk so cold starts (after the in-memory TTL expires) can still resolve region + * bboxes for the coverage pill without waiting on the network. `force` bypasses + * the stale-while-revalidate path and always hits the network. + */ +export async function fetchManifest(force = false): Promise { + if (!force && manifestCache && Date.now() - manifestCache.at < MANIFEST_TTL_MS) { + return manifestCache.data; + } + + if (!force) { + const stale = manifestCache?.data ?? readManifestFromDisk(); + if (stale) { + if (!manifestCache) manifestCache = { at: 0, data: stale }; + refreshManifestInBackground(); + return stale; + } + } + + if (manifestRefresh && !force) return manifestRefresh; + const pending = fetchManifestFromNetwork(); + if (!force) + manifestRefresh = pending.finally(() => { + manifestRefresh = null; + }); + return pending; +} + /** Region packs present on disk, read from their `.pack.json` sidecars. */ export function listLocal(regionsDir: string): InstalledRegionPack[] { if (!existsSync(regionsDir)) return []; @@ -287,6 +353,10 @@ export function deleteRegion(appStateDir: string, region: string): void { * for the lifetime of the window, alongside the other service IPCs. */ export function registerRegionPacksIpc(appStateDir: string): void { + manifestAppStateDir = appStateDir; + seedManifestCacheFromDisk(); + refreshManifestInBackground(); + ipcMain.handle("regions:get-manifest", (_e, force?: boolean) => fetchManifest(!!force)); ipcMain.handle("regions:list-local", () => listLocal(regionsDirFor(appStateDir))); ipcMain.handle( diff --git a/apps/dashboard/src/main/region-protocol.ts b/apps/dashboard/src/main/region-protocol.ts index 51e38985..c8f7c29c 100644 --- a/apps/dashboard/src/main/region-protocol.ts +++ b/apps/dashboard/src/main/region-protocol.ts @@ -107,6 +107,13 @@ type StyleLayer = { }; function styleResponse(regionsDir: string, theme: "light" | "dark", monochrome: boolean): Response { + const cacheKey = styleCacheKey(regionsDir, theme, monochrome); + if (styleCache?.key === cacheKey) { + return new Response(styleCache.body, { + headers: { "content-type": "application/json", ...CORS } + }); + } + // Theme × "Map color" → Protomaps flavor. The monochrome flavors ("black" / // "white") define no POI colors or icons, so graft the tinted flavor's POI // palette onto them (and reuse the tinted sprite sheet, a superset of icons). @@ -172,7 +179,9 @@ function styleResponse(regionsDir: string, theme: "light" | "dark", monochrome: sources, layers: [...worldLayers, ...regionLayers] }; - return new Response(JSON.stringify(style), { + const body = JSON.stringify(style); + styleCache = { key: cacheKey, body }; + return new Response(body, { headers: { "content-type": "application/json", ...CORS } }); } @@ -215,6 +224,22 @@ function archive(path: string): PMTiles { return a.pmtiles; } +/** Open the bundled world archive and read its header so the first map tile isn't cold. */ +export async function prewarmWorldArchive(worldPmtilesPath: string): Promise { + await archive(worldPmtilesPath).getHeader(); +} + +let styleCache: { key: string; body: string } | null = null; + +function styleCacheKey(regionsDir: string, theme: "light" | "dark", monochrome: boolean): string { + const slugs = listInstalledRegions(regionsDir) + .filter((r) => r.pmtiles) + .map((r) => r.region) + .sort() + .join(","); + return `${theme}:${monochrome ? "1" : "0"}:${slugs}`; +} + /** * Close every cached pmtiles file descriptor and drop the archive cache. Call * after a pack is deleted or re-downloaded — otherwise the cached archive keeps @@ -224,6 +249,7 @@ function archive(path: string): PMTiles { export function closeRegionArchives(): void { for (const { source } of archives.values()) source.close(); archives.clear(); + styleCache = null; } async function pmtilesTile(path: string, z: number, x: number, y: number): Promise { diff --git a/apps/dashboard/src/preload/index.d.ts b/apps/dashboard/src/preload/index.d.ts index 7e0dd6d5..10b46080 100644 --- a/apps/dashboard/src/preload/index.d.ts +++ b/apps/dashboard/src/preload/index.d.ts @@ -283,6 +283,7 @@ declare global { delete: (region: string) => Promise; onProgress: (cb: (data: RegionDownloadProgress) => void) => () => void; onChanged: (cb: () => void) => () => void; + onManifestUpdated: (cb: (data: RegionManifest) => void) => () => void; }; }; } diff --git a/apps/dashboard/src/preload/index.ts b/apps/dashboard/src/preload/index.ts index 9a366d5e..1f1b4e5d 100644 --- a/apps/dashboard/src/preload/index.ts +++ b/apps/dashboard/src/preload/index.ts @@ -409,6 +409,13 @@ const api = { return () => { ipcRenderer.off("regions:changed", listener); }; + }, + onManifestUpdated: (cb: (data: RegionManifest) => void): (() => void) => { + const listener = (_e: unknown, data: RegionManifest): void => cb(data); + ipcRenderer.on("regions:manifest-updated", listener); + return () => { + ipcRenderer.off("regions:manifest-updated", listener); + }; } } }; diff --git a/apps/dashboard/src/renderer/src/components/map/region-coverage-indicator.tsx b/apps/dashboard/src/renderer/src/components/map/region-coverage-indicator.tsx index 78a29ac4..c49685a5 100644 --- a/apps/dashboard/src/renderer/src/components/map/region-coverage-indicator.tsx +++ b/apps/dashboard/src/renderer/src/components/map/region-coverage-indicator.tsx @@ -52,9 +52,18 @@ export function RegionCoverageIndicator(): React.JSX.Element | null { useEffect(() => { const map = mapRef?.getMap(); if (!map) return; - sync(); // seed for the initial viewport + + const seedView = (): void => { + const c = map.getCenter(); + setView({ lng: c.lng, lat: c.lat, zoom: map.getZoom() }); + }; + + if (map.loaded()) seedView(); + else map.once("load", seedView); + map.on("moveend", sync); return () => { + map.off("load", seedView); map.off("moveend", sync); sync.cancel(); }; diff --git a/apps/dashboard/src/renderer/src/hooks/use-region-packs.ts b/apps/dashboard/src/renderer/src/hooks/use-region-packs.ts index fe80c9f3..082019b6 100644 --- a/apps/dashboard/src/renderer/src/hooks/use-region-packs.ts +++ b/apps/dashboard/src/renderer/src/hooks/use-region-packs.ts @@ -95,6 +95,10 @@ export function useRegionPacks(enabled: boolean): UseRegionPacks { // coverage indicator — stays in sync without waiting for a manual refresh. useEffect(() => window.api.regions.onChanged(() => void refreshLocal()), [refreshLocal]); + // Background manifest refresh (after a stale disk cache was returned) updates + // the catalog without forcing a full reload. + useEffect(() => window.api.regions.onManifestUpdated((data) => setManifest(data)), []); + // Stream download progress. "done" clears the row and re-reads local packs; // a cancellation just clears it; real errors stay so the row can show + offer retry. useEffect(() => {