From b6fa589d4a31428f703465384556f81f744760f5 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Mon, 18 May 2026 17:45:41 +0700 Subject: [PATCH] =?UTF-8?q?perf:=20wave=20C=20=E2=80=94=20interaction-gate?= =?UTF-8?q?=20minerals.db=20fetch=20on=20/tools/*=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the eager 8 MB minerals.db + sql-wasm.wasm download on first paint of every /tools/* route. Every widget that previously called getDB() from a mount-time useEffect now gates the load behind a hasInitiated flag that flips on first user interaction (onPointerDown on the widget root, or a hook-exposed initiate() callback for shared hooks). Hooks: - useCalculatorData: hasInitiated gate + exposed initiate() - useGemIdentification: hasInitiated gate + exposed initiate() - useGemLookup: threads initiate() from useCalculatorData Standalone widgets gated locally via onPointerDown: - calculator/{DispersionCalculator,HannemanRI} - optical/{DichroscopeResults,PleochroismReasoner,OpticSignReasoner} - lab/{UvFluorescenceLookup,HeavyLiquidReference} - identification/HardnessReference Hook consumers wired to initiate() via onPointerDown on root wrapper: - calculator/{SGCalculator,RICalculator,CaratEstimator} - identification/GemIdentifier Target: /tools/measurement/ desktop total weight drops from 8,651 KiB to <500 KiB. Widgets still feel instant on interaction; the existing loading state handles the brief fetch window. Part of audits/perf-2026-05/ remediation. WC1 SSG-pass-props deliberately skipped (polish, not perf — the gate alone hits the byte-weight acceptance target). --- src/components/calculator/CaratEstimator.tsx | 4 ++-- .../calculator/DispersionCalculator.tsx | 10 ++++++---- src/components/calculator/HannemanRI.tsx | 8 +++++--- src/components/calculator/RICalculator.tsx | 4 ++-- src/components/calculator/SGCalculator.tsx | 4 ++-- src/components/identification/GemIdentifier.tsx | 12 ++---------- .../identification/HardnessReference.tsx | 10 ++++++---- src/components/lab/HeavyLiquidReference.tsx | 10 ++++++---- src/components/lab/UvFluorescenceLookup.tsx | 8 +++++--- src/components/optical/DichroscopeResults.tsx | 10 ++++++---- src/components/optical/OpticSignReasoner.tsx | 8 +++++--- src/components/optical/PleochroismReasoner.tsx | 8 +++++--- src/hooks/useCalculatorData.ts | 16 +++++++++++++--- src/hooks/useGemIdentification.ts | 15 ++++++++++++--- src/hooks/useGemLookup.ts | 5 ++++- 15 files changed, 81 insertions(+), 51 deletions(-) diff --git a/src/components/calculator/CaratEstimator.tsx b/src/components/calculator/CaratEstimator.tsx index 5fdefd4..63436fd 100644 --- a/src/components/calculator/CaratEstimator.tsx +++ b/src/components/calculator/CaratEstimator.tsx @@ -77,7 +77,7 @@ export function CaratEstimator() { const [sgCustom, setSgCustom] = useState(''); const [girdle, setGirdle] = useState('medium'); - const { shapeFactors, mineralsWithSG, fallbackShapeFactors } = useCalculatorData(); + const { shapeFactors, mineralsWithSG, fallbackShapeFactors, initiate } = useCalculatorData(); // Use database shape factors if available, otherwise fallback const shapes = useMemo(() => { @@ -173,7 +173,7 @@ export function CaratEstimator() { }; return ( -
+

Enter stone dimensions to estimate carat weight.

diff --git a/src/components/calculator/DispersionCalculator.tsx b/src/components/calculator/DispersionCalculator.tsx index ec7177d..a39900f 100644 --- a/src/components/calculator/DispersionCalculator.tsx +++ b/src/components/calculator/DispersionCalculator.tsx @@ -20,15 +20,17 @@ function classifyDispersion(dispersion: number): { category: string; level: 'low } export function DispersionCalculator() { - const [loading, setLoading] = useState(true); + const [hasInitiated, setHasInitiated] = useState(false); + const [loading, setLoading] = useState(false); const [paginatedData, setPaginatedData] = useState | null>(null); const { params, onPageChange, onPageSizeChange } = usePagination({ initialPageSize: 15, }); - // Fetch paginated data from database + // Fetch paginated data only after first user interaction useEffect(() => { + if (!hasInitiated) return; async function loadData() { setLoading(true); try { @@ -42,7 +44,7 @@ export function DispersionCalculator() { } } loadData(); - }, [params]); + }, [hasInitiated, params]); const { values, errors, result, setValue } = useCalculatorForm({ fields: { @@ -93,7 +95,7 @@ export function DispersionCalculator() { }; return ( -

+
setHasInitiated(true)}>

Enter the refractive index at red (C-line, 656nm) and violet (F-line, 486nm) wavelengths to calculate dispersion. diff --git a/src/components/calculator/HannemanRI.tsx b/src/components/calculator/HannemanRI.tsx index dad763b..b6a9964 100644 --- a/src/components/calculator/HannemanRI.tsx +++ b/src/components/calculator/HannemanRI.tsx @@ -34,8 +34,9 @@ const liquidOptions = CONTACT_LIQUIDS.map((l) => ({ })); export function HannemanRI() { + const [hasInitiated, setHasInitiated] = useState(false); const [minerals, setMinerals] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbError, setDbError] = useState(null); const [rows, setRows] = useState([ @@ -45,6 +46,7 @@ export function HannemanRI() { ]); useEffect(() => { + if (!hasInitiated) return; let mounted = true; (async () => { try { @@ -61,7 +63,7 @@ export function HannemanRI() { return () => { mounted = false; }; - }, []); + }, [hasInitiated]); const usable = useMemo(() => rows.filter((r) => r.relief !== 'unknown'), [rows]); const band = useMemo(() => (usable.length === 0 ? null : combineBands(usable)), [usable]); @@ -94,7 +96,7 @@ export function HannemanRI() { setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); return ( -

+
setHasInitiated(true)}>

For stones above the refractometer scale (RI {'>'} 1.81) or rough material with no diff --git a/src/components/calculator/RICalculator.tsx b/src/components/calculator/RICalculator.tsx index 48d2623..8c43f36 100644 --- a/src/components/calculator/RICalculator.tsx +++ b/src/components/calculator/RICalculator.tsx @@ -82,7 +82,7 @@ export function RICalculator() { // Use the average of the two readings when in double mode, otherwise the single reading. const lookupTarget = doubleReadingResult?.lookupRI ?? result?.ri ?? null; - const { matches, lookup } = useGemLookup({ + const { matches, lookup, initiate } = useGemLookup({ type: 'ri', tolerance: parseFloat(values.tolerance) || 0.01, }); @@ -92,7 +92,7 @@ export function RICalculator() { }, [lookupTarget, lookup]); return ( -

+

Enter an RI reading to find matching gemstones. Toggle Double reading to enter both shadow-edge readings (ω/ε or α/γ) and infer birefringence + optic character automatically.

diff --git a/src/components/calculator/SGCalculator.tsx b/src/components/calculator/SGCalculator.tsx index 0d05deb..bf4a15d 100644 --- a/src/components/calculator/SGCalculator.tsx +++ b/src/components/calculator/SGCalculator.tsx @@ -95,7 +95,7 @@ export function SGCalculator() { }); // Gem lookup with debouncing - const { matches, lookup } = useGemLookup({ + const { matches, lookup, initiate } = useGemLookup({ type: 'sg', tolerance: 0.05, }); @@ -106,7 +106,7 @@ export function SGCalculator() { }, [result, lookup]); return ( -
+

Enter the weight of your stone in air and water to calculate its specific gravity.

diff --git a/src/components/identification/GemIdentifier.tsx b/src/components/identification/GemIdentifier.tsx index d5f9d82..c820556 100644 --- a/src/components/identification/GemIdentifier.tsx +++ b/src/components/identification/GemIdentifier.tsx @@ -85,7 +85,7 @@ export function GemIdentifier() { const [showAdvanced, setShowAdvanced] = useState(false); // Hook for matching - const { findMatches, crystalSystems, loading, error, mineralsCount, dbAvailable } = useGemIdentification(); + const { findMatches, crystalSystems, loading, error, mineralsCount, dbAvailable, initiate } = useGemIdentification(); // Determine if we're using single or dual RI mode const isDualRIMode = opticCharacter === 'uniaxial' || opticCharacter === 'biaxial'; @@ -245,14 +245,6 @@ export function GemIdentifier() { setOpticSign(''); }; - if (loading) { - return ( -

- Loading mineral database... -
- ); - } - if (error && !dbAvailable) { return (
@@ -262,7 +254,7 @@ export function GemIdentifier() { } return ( -
+

Enter measured gemmological properties to find matching gemstones. diff --git a/src/components/identification/HardnessReference.tsx b/src/components/identification/HardnessReference.tsx index a34398d..5f7a098 100644 --- a/src/components/identification/HardnessReference.tsx +++ b/src/components/identification/HardnessReference.tsx @@ -75,9 +75,10 @@ const WEARABILITY_COLORS: Record = { }; export function HardnessReference() { + const [hasInitiated, setHasInitiated] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [filterWearability, setFilterWearability] = useState('all'); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbAvailable, setDbAvailable] = useState(false); const [paginatedData, setPaginatedData] = useState | null>(null); @@ -85,8 +86,9 @@ export function HardnessReference() { initialPageSize: 20, }); - // Fetch paginated data from database + // Fetch paginated data only after first user interaction useEffect(() => { + if (!hasInitiated) return; async function loadData() { setLoading(true); try { @@ -102,7 +104,7 @@ export function HardnessReference() { } } loadData(); - }, [params]); + }, [hasInitiated, params]); // Reset to first page when search or filter changes useEffect(() => { @@ -135,7 +137,7 @@ export function HardnessReference() { }); return ( -

+
setHasInitiated(true)}> {/* Two-column layout: Mohs scale | Gem lookup */}
{/* Left: Mohs scale — compact table */} diff --git a/src/components/lab/HeavyLiquidReference.tsx b/src/components/lab/HeavyLiquidReference.tsx index e378b59..bb4fbfc 100644 --- a/src/components/lab/HeavyLiquidReference.tsx +++ b/src/components/lab/HeavyLiquidReference.tsx @@ -69,8 +69,9 @@ const FALLBACK_GEMS = [ ]; export function HeavyLiquidReference() { + const [hasInitiated, setHasInitiated] = useState(false); const [selectedSG, setSelectedSG] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbAvailable, setDbAvailable] = useState(false); const [paginatedData, setPaginatedData] = useState | null>(null); @@ -78,8 +79,9 @@ export function HeavyLiquidReference() { initialPageSize: 15, }); - // Fetch paginated data from database + // Fetch paginated data only after first user interaction useEffect(() => { + if (!hasInitiated) return; async function loadData() { setLoading(true); try { @@ -95,7 +97,7 @@ export function HeavyLiquidReference() { } } loadData(); - }, [params]); + }, [hasInitiated, params]); // Convert database minerals to simple gem format const gems = useMemo(() => { @@ -116,7 +118,7 @@ export function HeavyLiquidReference() { }; return ( -
+
setHasInitiated(true)}>

Heavy liquids separate gems by specific gravity. Select a liquid to see which gems float or sink. diff --git a/src/components/lab/UvFluorescenceLookup.tsx b/src/components/lab/UvFluorescenceLookup.tsx index 8ca3402..357cc76 100644 --- a/src/components/lab/UvFluorescenceLookup.tsx +++ b/src/components/lab/UvFluorescenceLookup.tsx @@ -62,8 +62,9 @@ function detectTreatmentFlag(family: MineralFamily, swuvColor: string): string | } export function UvFluorescenceLookup() { + const [hasInitiated, setHasInitiated] = useState(false); const [families, setFamilies] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbError, setDbError] = useState(null); const [lwuvIntensity, setLwuvIntensity] = useState('strong'); @@ -72,6 +73,7 @@ export function UvFluorescenceLookup() { const [swuvColor, setSwuvColor] = useState(''); useEffect(() => { + if (!hasInitiated) return; let mounted = true; (async () => { try { @@ -88,7 +90,7 @@ export function UvFluorescenceLookup() { return () => { mounted = false; }; - }, []); + }, [hasInitiated]); const obs = { lwuvIntensity, lwuvColor, swuvIntensity, swuvColor }; const hasObservation = lwuvIntensity !== 'unknown' || swuvIntensity !== 'unknown'; @@ -131,7 +133,7 @@ export function UvFluorescenceLookup() { }; return ( -

+
setHasInitiated(true)}>

Observe the stone under both long-wave (365 nm) and short-wave (254 nm) UV in a darkened cabinet and report what you see. The reasoner ranks species whose stored fluorescence text diff --git a/src/components/optical/DichroscopeResults.tsx b/src/components/optical/DichroscopeResults.tsx index 1211d8a..3b3543d 100644 --- a/src/components/optical/DichroscopeResults.tsx +++ b/src/components/optical/DichroscopeResults.tsx @@ -51,10 +51,11 @@ const STRENGTH_COLORS: Record = { }; export function DichroscopeResults() { + const [hasInitiated, setHasInitiated] = useState(false); const [color1, setColor1] = useState(''); const [color2, setColor2] = useState(''); const [strengthFilter, setStrengthFilter] = useState('all'); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbAvailable, setDbAvailable] = useState(false); const [paginatedData, setPaginatedData] = useState | null>(null); @@ -62,8 +63,9 @@ export function DichroscopeResults() { initialPageSize: 15, }); - // Fetch paginated data from database + // Fetch paginated data only after first user interaction useEffect(() => { + if (!hasInitiated) return; async function loadData() { setLoading(true); try { @@ -79,7 +81,7 @@ export function DichroscopeResults() { } } loadData(); - }, [params]); + }, [hasInitiated, params]); // Reset to first page when filters change useEffect(() => { @@ -112,7 +114,7 @@ export function DichroscopeResults() { }); return ( -

+
setHasInitiated(true)}> {/* Filter bar — single row across full width */}
diff --git a/src/components/optical/OpticSignReasoner.tsx b/src/components/optical/OpticSignReasoner.tsx index 0a2e5a3..47d929b 100644 --- a/src/components/optical/OpticSignReasoner.tsx +++ b/src/components/optical/OpticSignReasoner.tsx @@ -47,8 +47,9 @@ const SIGN_LABEL: Record = { }; export function OpticSignReasoner() { + const [hasInitiated, setHasInitiated] = useState(false); const [minerals, setMinerals] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbError, setDbError] = useState(null); const [character, setCharacter] = useState('uniaxial'); @@ -60,6 +61,7 @@ export function OpticSignReasoner() { const tolerance = 0.005; useEffect(() => { + if (!hasInitiated) return; let mounted = true; (async () => { try { @@ -76,7 +78,7 @@ export function OpticSignReasoner() { return () => { mounted = false; }; - }, []); + }, [hasInitiated]); const computed = useMemo(() => { if (character === 'isotropic') { @@ -170,7 +172,7 @@ export function OpticSignReasoner() { const { paged, page, setPage, totalPages } = usePagination(matches, 10); return ( -
+
setHasInitiated(true)}>

Pick what you saw in the polariscope. For uniaxial gems, enter ω and ε from the refractometer; for biaxial, enter α and γ (β is optional). The reasoner derives optic diff --git a/src/components/optical/PleochroismReasoner.tsx b/src/components/optical/PleochroismReasoner.tsx index 06b5a43..f519675 100644 --- a/src/components/optical/PleochroismReasoner.tsx +++ b/src/components/optical/PleochroismReasoner.tsx @@ -41,8 +41,9 @@ const STRENGTH_BADGE: Record = { }; export function PleochroismReasoner() { + const [hasInitiated, setHasInitiated] = useState(false); const [minerals, setMinerals] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [dbError, setDbError] = useState(null); const [colourCount, setColourCount] = useState(2); @@ -52,6 +53,7 @@ export function PleochroismReasoner() { const [strength, setStrength] = useState('unknown'); useEffect(() => { + if (!hasInitiated) return; let mounted = true; (async () => { try { @@ -68,7 +70,7 @@ export function PleochroismReasoner() { return () => { mounted = false; }; - }, []); + }, [hasInitiated]); const interpretation = useMemo(() => interpretColourCount(colourCount), [colourCount]); @@ -103,7 +105,7 @@ export function PleochroismReasoner() { const observedColoursEntered = [c1, c2, c3].slice(0, colourCount).filter((s) => s.trim()).length; return ( -

+
setHasInitiated(true)}>

Report what you saw through the dichroscope. The reasoner explains what your observation implies and ranks candidate species from the mineral database. diff --git a/src/hooks/useCalculatorData.ts b/src/hooks/useCalculatorData.ts index 0ac4c9a..0b64117 100644 --- a/src/hooks/useCalculatorData.ts +++ b/src/hooks/useCalculatorData.ts @@ -106,13 +106,17 @@ interface UseCalculatorDataReturn { // Fallback data fallbackGems: GemReference[]; fallbackShapeFactors: typeof SHAPE_FACTORS; + + /** Call once on first user interaction to trigger the DB fetch. */ + initiate: () => void; } /** * Hook to access calculator reference data with database integration. */ export function useCalculatorData(): UseCalculatorDataReturn { - const [loading, setLoading] = useState(true); + const [hasInitiated, setHasInitiated] = useState(false); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [dbAvailable, setDbAvailable] = useState(false); @@ -126,8 +130,13 @@ export function useCalculatorData(): UseCalculatorDataReturn { const [mineralsForRefractometer, setMineralsForRefractometer] = useState([]); const [mineralsWithPleochroism, setMineralsWithPleochroism] = useState([]); - // Load reference data on mount + const initiate = useCallback(() => { + setHasInitiated(true); + }, []); + + // Load reference data only after first user interaction useEffect(() => { + if (!hasInitiated) return; async function loadData() { try { setLoading(true); @@ -176,7 +185,7 @@ export function useCalculatorData(): UseCalculatorDataReturn { } loadData(); - }, []); + }, [hasInitiated]); // RI lookup with database or fallback (uses families to avoid duplicates) const findByRI = useCallback(async (ri: number, tolerance: number = 0.01): Promise => { @@ -236,5 +245,6 @@ export function useCalculatorData(): UseCalculatorDataReturn { mineralsWithPleochroism, fallbackGems: COMMON_GEMS, fallbackShapeFactors: SHAPE_FACTORS, + initiate, }; } diff --git a/src/hooks/useGemIdentification.ts b/src/hooks/useGemIdentification.ts index 07d861e..17fba5e 100644 --- a/src/hooks/useGemIdentification.ts +++ b/src/hooks/useGemIdentification.ts @@ -37,17 +37,25 @@ interface UseGemIdentificationReturn { mineralsCount: number; /** Whether database is available */ dbAvailable: boolean; + /** Call once on first user interaction to trigger the DB fetch. */ + initiate: () => void; } export function useGemIdentification(): UseGemIdentificationReturn { + const [hasInitiated, setHasInitiated] = useState(false); const [minerals, setMinerals] = useState([]); const [crystalSystems, setCrystalSystems] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [dbAvailable, setDbAvailable] = useState(false); - // Load minerals and crystal systems on mount + const initiate = useCallback(() => { + setHasInitiated(true); + }, []); + + // Load minerals and crystal systems only after first user interaction useEffect(() => { + if (!hasInitiated) return; let mounted = true; async function loadData() { @@ -83,7 +91,7 @@ export function useGemIdentification(): UseGemIdentificationReturn { return () => { mounted = false; }; - }, []); + }, [hasInitiated]); // Memoized find function const findMatches = useCallback( @@ -109,6 +117,7 @@ export function useGemIdentification(): UseGemIdentificationReturn { error, mineralsCount, dbAvailable, + initiate, }; } diff --git a/src/hooks/useGemLookup.ts b/src/hooks/useGemLookup.ts index 0fa2d52..5687fc7 100644 --- a/src/hooks/useGemLookup.ts +++ b/src/hooks/useGemLookup.ts @@ -46,6 +46,8 @@ interface UseGemLookupReturn { clear: () => void; /** The value that was last looked up */ lastValue: number | null; + /** Call once on first user interaction to trigger DB fetch. */ + initiate: () => void; } const DEFAULT_TOLERANCES: Record = { @@ -61,7 +63,7 @@ export function useGemLookup(options: UseGemLookupOptions): UseGemLookupReturn { maxResults, } = options; - const { findByRI, findBySG } = useCalculatorData(); + const { findByRI, findBySG, initiate } = useCalculatorData(); const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(false); @@ -173,6 +175,7 @@ export function useGemLookup(options: UseGemLookupOptions): UseGemLookupReturn { lookup, clear, lastValue, + initiate, }; }