diff --git a/src/components/address-page.tsx b/src/components/address-page.tsx index 1a270a6..b8a96fa 100644 --- a/src/components/address-page.tsx +++ b/src/components/address-page.tsx @@ -1,85 +1,207 @@ "use client"; -import { useEffect, useState, type ComponentType } from "react"; -import type { City } from "@/lib/types"; +import { + useCallback, + useEffect, + useState, + memo, + type ComponentType, +} from "react"; +import type { City, District } from "@/lib/types"; import { useAddressState } from "@/hooks/use-address-state"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { AddressForm } from "@/components/address-form"; -// Module-level: start downloading the map chunk during hydration (before any -// useEffect fires). Only on desktop to avoid wasting mobile bandwidth. -// The bundler caches the module, so subsequent import() calls in useEffect -// resolve instantly without an extra network request. -// Also prime the HTTP cache for counties data in parallel. -const isDesktopAtLoad = +/* ------------------------------------------------------------------ */ +/* Module-level preload */ +/* ------------------------------------------------------------------ */ + +let mapPromise: Promise | null = null; + +/** Returns (and caches) the dynamic import promise. */ +function loadMapModule() { + mapPromise ??= import("@/components/taiwan-map"); + return mapPromise; +} + +// Fire-and-forget preload on desktop — runs during hydration, before any +// useEffect. The explicit `mapPromise` ref guarantees dedup regardless of +// bundler internals. +if ( typeof window !== "undefined" && - window.matchMedia("(min-width: 1024px)").matches; + window.matchMedia("(min-width: 1024px)").matches +) { + void loadMapModule(); + + // Prefetch GeoJSON via so the browser stores it in + // the HTTP cache with correct headers. A bare fetch() can miss the cache + // if TaiwanMap internally uses different request options. + const link = Object.assign(document.createElement("link"), { + rel: "prefetch", + href: "/data/map/counties-10t.json", + as: "fetch", + crossOrigin: "anonymous", + }); + document.head.appendChild(link); +} + +/* ------------------------------------------------------------------ */ +/* Types & constants */ +/* ------------------------------------------------------------------ */ + +const MAX_RETRIES = 2; + +interface TaiwanMapProps { + city: City | null; + district: District | null; + zip6: string | null; +} + +type MapLoadState = + | { status: "idle" } + | { status: "loading" } + | { status: "ready"; Component: ComponentType } + | { status: "error" }; + +/* ------------------------------------------------------------------ */ +/* Hook: lazy map loader */ +/* ------------------------------------------------------------------ */ + +function useLazyMap() { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(() => { + let cancelled = false; + let attempt = 0; + + const tryLoad = () => { + setState({ status: "loading" }); + + loadMapModule() + .then((mod) => { + if (!cancelled) { + setState({ status: "ready", Component: mod.TaiwanMap }); + } + }) + .catch(() => { + mapPromise = null; // allow fresh import on next attempt + if (cancelled) return; -if (isDesktopAtLoad) { - void import("@/components/taiwan-map"); - void fetch("/data/map/counties-10t.json"); + if (++attempt <= MAX_RETRIES) { + tryLoad(); + } else { + setState({ status: "error" }); + } + }); + }; + + tryLoad(); + + return () => { + cancelled = true; + }; + }, []); + + const retry = useCallback(() => { + mapPromise = null; + load(); + }, [load]); + + return { state, load, retry } as const; } +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + const MapPlaceholder = () => (
); +const MapError = ({ onRetry }: { onRetry: () => void }) => ( +
+

地圖載入失敗

+ +
+); + +/** Memo prevents re-renders when unrelated address fields change. */ +const LazyMap = memo(function LazyMap({ + Component, + city, + district, + zip6, +}: { + Component: ComponentType; + city: City | null; + district: District | null; + zip6: string | null; +}) { + return ; +}); + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + interface AddressPageProps { cities: City[]; } export function AddressPage({ cities }: AddressPageProps) { const addressState = useAddressState(); - const isDesktop = useMediaQuery("(min-width: 1024px)"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [TaiwanMap, setTaiwanMap] = useState | null>(null); + const { state: mapState, load, retry } = useLazyMap(); + // Trigger map load when viewport hits desktop width. + // Listens for resize so tablet rotation / window expansion still works. useEffect(() => { - if (!isDesktop) return; + const mql = window.matchMedia("(min-width: 1024px)"); + let cleanup: (() => void) | undefined; + let loaded = false; - let cancelled = false; + const onChange = () => { + if (mql.matches && !loaded) { + loaded = true; + cleanup = load(); + } + }; - // The bundler caches dynamic imports, so this resolves instantly if the - // module-level preload already completed. No module-level promise ref - // needed -- avoids Strict-Mode / HMR race conditions and adds retry on - // transient chunk-load failures. - import("@/components/taiwan-map").then( - (mod) => { - if (!cancelled) setTaiwanMap(() => mod.TaiwanMap); - }, - () => { - // Retry once on transient chunk-load failure - if (!cancelled) { - void import("@/components/taiwan-map").then((mod) => { - if (!cancelled) setTaiwanMap(() => mod.TaiwanMap); - }); - } - }, - ); + onChange(); // check immediately + mql.addEventListener("change", onChange); return () => { - cancelled = true; + mql.removeEventListener("change", onChange); + cleanup?.(); }; - }, [isDesktop]); + }, [load]); return (
- {isDesktop && ( -
-
- {TaiwanMap ? ( - - ) : ( - - )} -
+ + {/* Always in the DOM — SSR grid is correct, zero layout shift. + CSS `hidden lg:block` toggles visibility without JS. */} +
+
+ {mapState.status === "ready" ? ( + + ) : mapState.status === "error" ? ( + + ) : ( + + )}
- )} +
); }