Next.js SSG中Suspense触发水化错误的排查求助
维护基于Next.js SSG的静态站点,需渲染从Supabase获取的活动列表。该功能在React 17中运行正常,但升级至React 18后,页面硬刷新时偶发出现水化错误,且Suspense的fallback组件无法稳定展示。站点通过Cookie和服务端中间件完成认证。
错误信息
Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.
相关代码
index.tsx(页面组件)
//index.tsx (page component) import { AddEventButton } from '@/components/index'; import { ComponentWithLayout } from '@/types/definitions'; import { Suspense } from 'react'; import { getNavbarLayout } from '@/layouts/NavbarLayout'; import UpcomingEventListSkeleton from '@/components/upcomingEventList/UpcomingEventList.skeleton'; import dynamic from 'next/dynamic'; const UpcomingEventList = dynamic( () => import('@/components/upcomingEventList/UpcomingEventList'), { suspense: true } ); const UpcomingEventsPage: ComponentWithLayout = () => { return ( <div className="mx-auto lg:w-1/2 xs:w-full"> <div className="space-y-2"> <Suspense fallback={<UpcomingEventListSkeleton />}> <UpcomingEventList /> </Suspense> </div> <AddEventButton /> </div> ); }; UpcomingEventsPage.getLayout = getNavbarLayout; export default UpcomingEventsPage;
UpcomingEventList.tsx
//UpcomingEventList.tsx import { CalendarEvent } from '@/types/definitions'; import { CalendarEventConfiguration } from '@/utils/appConfig'; import { UpcomingEvent } from './components/upcomingEvent/UpcomingEvent'; import { supabaseClient } from '@supabase/auth-helpers-nextjs'; import React from 'react'; import dayjs from 'dayjs'; import useSWR from 'swr'; /** * Shows a list of Upcoming Event components * @returns UpcomingEventList component */ export const UpcomingEventList = () => { const { data } = useSWR( 'upcomingEvents', async () => await supabaseClient .from<CalendarEvent>(CalendarEventConfiguration.database.tableName) .select('*, owner: user_profile(full_name, avatar)') .limit(10) .order('end', { ascending: true }) .gt('end', dayjs(new Date()).toISOString()), { refreshInterval: 3000, suspense: true, } ); return ( <> {data && data.data?.length === 0 && ( <div className="card shadow bg-base-100 mx-auto w-full border text-center p-5"> <div className="text-base-content font-medium"> No upcoming events scheduled </div> <div className="text-sm opacity-50"> Events can be scheduled by clicking the purple button in the bottom-right corner. </div> </div> )} {data && data.data?.map( ({ id, owner, start, end, number_of_guests, privacy_requested }) => ( <UpcomingEvent key={id} title={owner?.full_name} startDate={start} endDate={end} numberOfGuests={number_of_guests} privacyRequested={privacy_requested} avatarSrc={owner?.avatar} /> ) )} </> ); }; export default UpcomingEventList;
UpcomingEventListSkeleton.tsx
//UpcomingEventListSkeleton.tsx import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faEye, faPlayCircle, faStopCircle, faUser, } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; const UpcomingEventListSkeleton = () => ( <> {[...Array(5).keys()].map((v, i) => ( <UpcomingEventSkeleton key={i} /> ))} </> ); export default UpcomingEventListSkeleton; const UpcomingEventSkeleton = () => ( <div className="card shadow bg-base-100 text-base-content font-medium mx-auto w-full border"> <div className="flex items-center m-2 animate-pulse"> <div className="mr-5 ml-1"> <div className="rounded-full bg-gray-200 h-12 w-12"></div> </div> <span className="space-y-1"> <div className="text-xl"> <div className="h-8 bg-gray-200 rounded w-56"></div> </div> <div className="text-sm opacity-50" style={{ whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', width: '20em', }} > <div className="grid grid-cols-10 grid-rows-4"> <div> <FontAwesomeIcon icon={faPlayCircle} className="col-span-1" /> </div> <div className="h-2 bg-gray-200 rounded col-span-9 mt-1 w-36"></div> <div> <FontAwesomeIcon icon={faStopCircle} /> </div> <div className="h-2 bg-gray-200 rounded col-span-9 mt-1 w-36"></div> <div> <FontAwesomeIcon icon={faUser} /> </div> <div className="h-2 bg-gray-200 rounded col-span-9 mt-1 w-36"></div> <div> <FontAwesomeIcon icon={faEye} /> </div> <div className="h-2 bg-gray-200 rounded col-span-9 mt-1 w-36"></div> </div> </div> </span> </div> </div> );
已尝试调整组件结构、使用useEffect强制客户端渲染等方案,但均未解决问题。即使简化组件为纯文本,问题仍复现。不清楚如何结合useSWR使用useTransition来解决该问题,且多数同类问题出现在SSR场景。
1. 用useTransition标记非紧急更新
React 18中,Suspense水化阶段若遇到同步状态更新会触发错误,需将数据更新标记为过渡态。修改UpcomingEventList.tsx:
import { useTransition } from 'react'; // ...其他导入 export const UpcomingEventList = () => { const [isPending, startTransition] = useTransition(); const { data } = useSWR( 'upcomingEvents', async () => { // 原请求逻辑不变 return await supabaseClient .from<CalendarEvent>(CalendarEventConfiguration.database.tableName) .select('*, owner: user_profile(full_name, avatar)') .limit(10) .order('end', { ascending: true }) .gt('end', dayjs(new Date()).toISOString()); }, { suspense: true, refreshInterval: 3000, // 用startTransition包裹数据更新后的渲染 onSuccess: () => { startTransition(() => {}); } } ); // 渲染时用startTransition包裹数据相关内容 return ( <> {startTransition(() => ( <> {data && data.data?.length === 0 && ( <div className="card shadow bg-base-100 mx-auto w-full border text-center p-5"> <div className="text-base-content font-medium"> No upcoming events scheduled </div> <div className="text-sm opacity-50"> Events can be scheduled by clicking the purple button in the bottom-right corner. </div> </div> )} {data && data.data?.map( ({ id, owner, start, end, number_of_guests, privacy_requested }) => ( <UpcomingEvent key={id} title={owner?.full_name} startDate={start} endDate={end} numberOfGuests={number_of_guests} privacyRequested={privacy_requested} avatarSrc={owner?.avatar} /> ) )} </> ))} </> ); };
2. 延迟启用SWR轮询
当前refreshInterval: 3000会在页面加载后立即触发轮询,可能打断水化过程。可以等客户端挂载完成后再启用轮询:
import { useEffect, useState } from 'react'; export const UpcomingEventList = () => { const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); const { data } = useSWR( 'upcomingEvents', // 原请求逻辑 async () => await supabaseClient .from<CalendarEvent>(CalendarEventConfiguration.database.tableName) .select('*, owner: user_profile(full_name, avatar)') .limit(10) .order('end', { ascending: true }) .gt('end', dayjs(new Date()).toISOString()), { suspense: true, refreshInterval: isClient ? 3000 : 0, } ); // 原渲染逻辑 };
3. 关闭SWR的Suspense选项,手动处理加载状态
若上述方法无效,可暂时关闭SWR的suspense配置,手动控制加载状态,避免依赖React Suspense的自动水化:
export const UpcomingEventList = () => { const { data, isLoading } = useSWR( 'upcomingEvents', // 原请求逻辑 async () => await supabaseClient .from<CalendarEvent>(CalendarEventConfiguration.database.tableName) .select('*, owner: user_profile(full_name, avatar)') .limit(10) .order('end', { ascending: true }) .gt('end', dayjs(new Date()).toISOString()), { suspense: false, refreshInterval: 3000, } ); if (isLoading) { return <UpcomingEventListSkeleton />; } // 原渲染逻辑 };
同时去掉页面组件中的Suspense包裹,直接渲染UpcomingEventList。
4. 确保服务端与客户端数据一致性
SSG中,服务端生成的HTML与客户端水化时的数据可能因动态值(如时间戳)不一致导致水化错误。可在getStaticProps中传递统一的时间参数:
// 在index.tsx中添加getStaticProps export const getStaticProps = async () => { const currentTime = dayjs(new Date()).toISOString(); return { props: { currentTime }, revalidate: 60, // 按需设置页面重新生成间隔 }; }; // 修改UpcomingEventList接收currentTime参数 export const UpcomingEventList = ({ currentTime }) => { const { data } = useSWR( 'upcomingEvents', async () => await supabaseClient .from<CalendarEvent>(CalendarEventConfiguration.database.tableName) .select('*, owner: user_profile(full_name, avatar)') .limit(10) .order('end', { ascending: true }) .gt('end', currentTime), // 使用服务端传递的时间 { suspense: true, refreshInterval: 3000, } ); // 原渲染逻辑 };
内容的提问来源于stack exchange,提问作者Lee Harrison

