add lazy loading attributes to images and fix potential runaway timers in main page scroll logic

This commit is contained in:
2026-05-01 17:23:27 +08:00
parent efa37da996
commit 872423cbdd
8 changed files with 52 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { Box, Image, Button, HStack, VStack, Text } from '@chakra-ui/react' import { Box, Image, Button, HStack, VStack, Text } from '@chakra-ui/react'
import { useState, useEffect, CSSProperties } from 'react' import { useState, useEffect, useMemo, useRef, CSSProperties } from 'react'
interface CyclingImageProps { interface CyclingImageProps {
src: string src: string
@@ -49,6 +49,12 @@ const CyclingImage = ({
const [currentIntensity, setCurrentIntensity] = useState(intensity) const [currentIntensity, setCurrentIntensity] = useState(intensity)
const [currentImage, setCurrentImage] = useState(src) const [currentImage, setCurrentImage] = useState(src)
// Keep refs to latest src/src2 so the setInterval callback is never stale
const srcRef = useRef(src)
const src2Ref = useRef(src2)
useEffect(() => { srcRef.current = src }, [src])
useEffect(() => { src2Ref.current = src2 }, [src2])
useEffect(() => { useEffect(() => {
setSpeed(cycleDuration) setSpeed(cycleDuration)
}, [cycleDuration]) }, [cycleDuration])
@@ -58,11 +64,12 @@ const CyclingImage = ({
if (!src2 || !isPlaying) return if (!src2 || !isPlaying) return
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentImage(prev => prev === src ? src2 : src) // Use refs to read latest prop values, avoiding stale closures
setCurrentImage(prev => prev === srcRef.current ? src2Ref.current! : srcRef.current)
}, speed * 1000) }, speed * 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [src, src2, speed, isPlaying]) }, [src2, speed, isPlaying])
const togglePlayPause = () => { const togglePlayPause = () => {
setIsPlaying(!isPlaying) setIsPlaying(!isPlaying)
@@ -76,9 +83,10 @@ const CyclingImage = ({
setCurrentIntensity(value) setCurrentIntensity(value)
} }
// CSS keyframes animation // CSS keyframes animation — memoized so the <style> tag is only
// re-written when the intensity value actually changes.
const animationName = 'lightToDarkCycle' const animationName = 'lightToDarkCycle'
const animationStyle = ` const animationStyle = useMemo(() => `
@keyframes ${animationName} { @keyframes ${animationName} {
0% { 0% {
filter: brightness(${1 - currentIntensity * 1.5}); filter: brightness(${1 - currentIntensity * 1.5});
@@ -90,7 +98,7 @@ const CyclingImage = ({
filter: brightness(${1 - currentIntensity * 1.5}); filter: brightness(${1 - currentIntensity * 1.5});
} }
} }
` `, [currentIntensity])
return ( return (
<> <>

View File

@@ -208,6 +208,7 @@ function Advantages() {
src='/images/new/heart.webp' src='/images/new/heart.webp'
title="獅球嘜營萃護心油" title="獅球嘜營萃護心油"
alt="獅球嘜營萃護心油" alt="獅球嘜營萃護心油"
loading="lazy"
w={{ base: '75vw', sm: '75vw', md: '40vw', lg: '40vw', xl: '40vw' }} w={{ base: '75vw', sm: '75vw', md: '40vw', lg: '40vw', xl: '40vw' }}
/> />

View File

@@ -1,6 +1,6 @@
import { Box, Image, Stack, Text } from '@chakra-ui/react' import { Box, Image, Stack, Text } from '@chakra-ui/react'
import CyclingImage from './CyclingImage' import CyclingImage from './CyclingImage'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
const MotionImage = motion.create(Image) const MotionImage = motion.create(Image)
@@ -9,6 +9,7 @@ const MotionText = motion.create(Text)
function Hero1() { function Hero1() {
const [headerHeight, setHeaderHeight] = useState(80); const [headerHeight, setHeaderHeight] = useState(80);
const bigWarningSize = { base: "17vw", sm: "15vw", md: "9vw", lg: "9vw", xl: "8vw" }; const bigWarningSize = { base: "17vw", sm: "15vw", md: "9vw", lg: "9vw", xl: "8vw" };
const delayedUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
const updateHeaderHeight = () => { const updateHeaderHeight = () => {
@@ -20,13 +21,15 @@ function Hero1() {
}; };
const delayedUpdate = () => { const delayedUpdate = () => {
setTimeout(updateHeaderHeight, 100); clearTimeout(delayedUpdateTimerRef.current);
delayedUpdateTimerRef.current = setTimeout(updateHeaderHeight, 100);
}; };
delayedUpdate(); delayedUpdate();
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
setTimeout(updateHeaderHeight, 50); clearTimeout(delayedUpdateTimerRef.current);
delayedUpdateTimerRef.current = setTimeout(updateHeaderHeight, 50);
}); });
const header = document.querySelector('[data-header="true"]'); const header = document.querySelector('[data-header="true"]');
@@ -38,6 +41,7 @@ function Hero1() {
return () => { return () => {
window.removeEventListener('resize', delayedUpdate); window.removeEventListener('resize', delayedUpdate);
clearTimeout(delayedUpdateTimerRef.current);
resizeObserver.disconnect(); resizeObserver.disconnect();
}; };
}, []); }, []);

View File

@@ -34,6 +34,7 @@ function Hero2() {
left="50%" left="50%"
title="獅球嘜營萃護心油" title="獅球嘜營萃護心油"
alt="獅球嘜營萃護心油" alt="獅球嘜營萃護心油"
loading="lazy"
top={{ base: "10px", sm: "20px", md: "30px", lg: "30px", xl: "40px" }} top={{ base: "10px", sm: "20px", md: "30px", lg: "30px", xl: "40px" }}
w={{ base: "70vw", sm: "70vw", md: "35vw", lg: "35vw", xl: "28vw" }} w={{ base: "70vw", sm: "70vw", md: "35vw", lg: "35vw", xl: "28vw" }}
initial={{ opacity: 0, y: -30, x: '-50%' }} initial={{ opacity: 0, y: -30, x: '-50%' }}
@@ -45,6 +46,7 @@ function Hero2() {
src="/images/new/hero2subtitle.webp" src="/images/new/hero2subtitle.webp"
position={'absolute'} position={'absolute'}
zIndex={1} zIndex={1}
loading="lazy"
top={{ base: "35vw", sm: "35vw", md: "17vw", lg: "15vw", xl: "12vw" }} top={{ base: "35vw", sm: "35vw", md: "17vw", lg: "15vw", xl: "12vw" }}
right={{ base: "55vw", sm: "55vw", md: "24vw", lg: "25vw", xl: "30vw" }} right={{ base: "55vw", sm: "55vw", md: "24vw", lg: "25vw", xl: "30vw" }}
w={{ base: "34vw", sm: "34vw", md: "22vw", lg: "22vw", xl: "18vw" }} w={{ base: "34vw", sm: "34vw", md: "22vw", lg: "22vw", xl: "18vw" }}
@@ -59,6 +61,7 @@ function Hero2() {
alt="獅球嘜營萃護心油" alt="獅球嘜營萃護心油"
title="獅球嘜營萃護心油" title="獅球嘜營萃護心油"
zIndex={0} zIndex={0}
loading="lazy"
position={'absolute'} position={'absolute'}
left="50%" left="50%"
bottom={0} bottom={0}

View File

@@ -85,6 +85,7 @@ function Info() {
src='/images/new/info_title.webp' src='/images/new/info_title.webp'
w={{ base: '90vw', sm: '80vw', md: '45vw', lg: '35vw', xl: '22vw' }} w={{ base: '90vw', sm: '80vw', md: '45vw', lg: '35vw', xl: '22vw' }}
mt={'50px'} mt={'50px'}
loading="lazy"
initial={{ opacity: 0, y: -30 }} initial={{ opacity: 0, y: -30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }} viewport={{ once: true, amount: 0.3 }}
@@ -156,7 +157,7 @@ function Info() {
src={infoData.find(info => info.id === selectedInfo)?.image} src={infoData.find(info => info.id === selectedInfo)?.image}
w="100%" w="100%"
p={5} p={5}
loading="lazy"
/> />
<Box px={{ base: 5, sm: 5, md: 6, lg: 5, xl: 5 }} <Box px={{ base: 5, sm: 5, md: 6, lg: 5, xl: 5 }}
pb={{ base: 5, sm: 5, md: 6, lg: 9, xl: 9 }}> pb={{ base: 5, sm: 5, md: 6, lg: 9, xl: 9 }}>

View File

@@ -68,6 +68,7 @@ function OilInfo() {
src='/images/cookmethods.webp' src='/images/cookmethods.webp'
alt='烹調方法' alt='烹調方法'
title='烹調方法' title='烹調方法'
loading="lazy"
w={{ base: '100%', sm: '90%', md: '85%' }} w={{ base: '100%', sm: '90%', md: '85%' }}
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }} animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }}
@@ -109,6 +110,7 @@ function OilInfo() {
<MotionImage <MotionImage
src='/images/new/buttons.webp' src='/images/new/buttons.webp'
w={{ base: '100%', sm: '100%', md: '90%' }} w={{ base: '100%', sm: '100%', md: '90%' }}
loading="lazy"
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }} animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.0, ease: "easeOut" }} transition={{ duration: 0.5, delay: 1.0, ease: "easeOut" }}

View File

@@ -91,7 +91,11 @@ function Truth() {
// Parse XFBML when SDK is loaded // Parse XFBML when SDK is loaded
const handleSDKLoad = () => { const handleSDKLoad = () => {
if ((window as any).FB && fbContainerRef.current) { if ((window as any).FB && fbContainerRef.current) {
(window as any).FB.XFBML.parse(fbContainerRef.current); try {
(window as any).FB.XFBML.parse(fbContainerRef.current);
} catch (_e) {
// FB SDK may fail (e.g. blocked by ad-blockers) — ignore silently
}
} }
}; };

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useRouterState } from '@tanstack/react-router' import { useRouterState } from '@tanstack/react-router'
import Hero1 from '../components/new_ui/hero1' import Hero1 from '../components/new_ui/hero1'
import Hero2 from '../components/new_ui/hero2' import Hero2 from '../components/new_ui/hero2'
@@ -16,23 +16,26 @@ import Header from '@/components/header'
function MainPage() { function MainPage() {
const { location } = useRouterState() const { location } = useRouterState()
// Refs must live at component top level (Rules of Hooks).
// Storing timer IDs in refs means the cleanup closure always reads the
// latest value, preventing runaway timers after the component unmounts.
const animationFrameRef = useRef(0)
const retryTimeoutRef = useRef<number | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (location.pathname !== '/40plus') { if (location.pathname !== '/40plus') {
return return
} }
let animationFrame = 0
let retryTimeout: number | undefined
// Replay pending section scroll requests written to sessionStorage // Replay pending section scroll requests written to sessionStorage
const clearTimers = () => { const clearTimers = () => {
if (animationFrame) { if (animationFrameRef.current) {
window.cancelAnimationFrame(animationFrame) window.cancelAnimationFrame(animationFrameRef.current)
animationFrame = 0 animationFrameRef.current = 0
} }
if (retryTimeout) { if (retryTimeoutRef.current) {
window.clearTimeout(retryTimeout) window.clearTimeout(retryTimeoutRef.current)
retryTimeout = undefined retryTimeoutRef.current = undefined
} }
} }
@@ -44,17 +47,17 @@ function MainPage() {
if (element) { if (element) {
window.sessionStorage.removeItem('pendingScrollSection') window.sessionStorage.removeItem('pendingScrollSection')
element.scrollIntoView({ behavior: 'smooth' }) element.scrollIntoView({ behavior: 'smooth' })
animationFrame = 0 animationFrameRef.current = 0
retryTimeout = undefined retryTimeoutRef.current = undefined
return return
} }
retryTimeout = window.setTimeout(() => { retryTimeoutRef.current = window.setTimeout(() => {
animationFrame = window.requestAnimationFrame(scrollToTarget) animationFrameRef.current = window.requestAnimationFrame(scrollToTarget)
}, 50) }, 50)
} }
animationFrame = window.requestAnimationFrame(scrollToTarget) animationFrameRef.current = window.requestAnimationFrame(scrollToTarget)
} }
const runScroll = () => { const runScroll = () => {