Optimize Next.js 16 behavior (#349)

* Remove unused desktop redirect component

* Tweak useEffect/setState interactions

* Address more next.js 16 linting

* Tweak secret loading

* Finish linting setstate/useeffect interactions

* Disable ref lint warnings
This commit is contained in:
Sam Becker 2025-10-27 09:49:16 -05:00 committed by GitHub
parent d061051803
commit dbf55badf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 465 additions and 495 deletions

View File

@ -18,9 +18,7 @@ const eslintConfig = defineConfig([
'@stylistic': stylistic,
},
rules: {
// Temporarily disable during Next.js 16 migration
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'warn',
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',

View File

@ -1,7 +1,7 @@
{
"name": "exif-photo-blog",
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@ -10,8 +10,8 @@
},
"packageManager": "pnpm@10.19.0",
"dependencies": {
"@ai-sdk/openai": "^2.0.53",
"@ai-sdk/rsc": "^1.0.79",
"@ai-sdk/openai": "^2.0.54",
"@ai-sdk/rsc": "^1.0.81",
"@aws-sdk/client-s3": "3.917.0",
"@aws-sdk/s3-request-presigner": "3.917.0",
"@radix-ui/react-dialog": "^1.1.15",
@ -24,7 +24,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^2.0.0",
"@vercel/speed-insights": "^1.2.0",
"ai": "^5.0.79",
"ai": "^5.0.81",
"camelcase-keys": "^10.0.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -37,14 +37,14 @@
"fast-deep-equal": "^3.1.3",
"framer-motion": "^12.23.24",
"nanoid": "^5.1.6",
"next": "15.5.5",
"next": "16.0.0",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"ol": "^10.6.1",
"pg": "^8.16.3",
"piexifjs": "^1.0.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-icons": "^5.5.0",
"react-openlayers": "^10.5.1",
"sanitize-html": "^2.17.0",
@ -58,8 +58,8 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@next/bundle-analyzer": "15.5.5",
"@next/eslint-plugin-next": "15.5.5",
"@next/bundle-analyzer": "16.0.0",
"@next/eslint-plugin-next": "16.0.0",
"@stylistic/eslint-plugin": "^5.5.0",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/dom": "^10.4.1",
@ -74,8 +74,7 @@
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.38.0",
"eslint-config-next": "15.5.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-config-next": "16.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "8.5.6",

724
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import {
PREFIX_TAG,
} from './src/app/path';
export default function middleware(req: NextRequest, res:NextResponse) {
export function proxy(req: NextRequest, res:NextResponse) {
const pathname = req.nextUrl.pathname;
if (pathname === PATH_ADMIN) {

View File

@ -53,11 +53,10 @@ export default function AdminNavClient({
useState(areTimesRecent(updateTimes));
useEffect(() => {
// Check every 5 seconds if update times are recent
setHasRecentUpdates(areTimesRecent(updateTimes));
// Check every 1 second if update times are recent
const interval = setInterval(() =>
setHasRecentUpdates(areTimesRecent(updateTimes))
, 5_000);
, 1_000);
return () => clearInterval(interval);
}, [updateTimes]);

View File

@ -2,16 +2,19 @@ import { Suspense } from 'react';
import { APP_CONFIGURATION } from '@/app/config';
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
import { generateAuthSecret } from '@/auth';
export default function AdminAppConfiguration({
export default async function AdminAppConfiguration({
simplifiedView,
}: {
simplifiedView?: boolean
}) {
const secret = await generateAuthSecret();
return (
<Suspense fallback={<AdminAppConfigurationClient {...{
...APP_CONFIGURATION,
isAnalyzingConfiguration: true,
secret,
simplifiedView,
}} /> }>
<AdminAppConfigurationServer {...{ simplifiedView }} />

View File

@ -137,6 +137,8 @@ export default function AdminAppConfigurationClient({
areInternalToolsEnabled,
areAdminDebugToolsEnabled,
isAdminSqlDebugEnabled,
// Auth
secret,
// Connection status
databaseError,
storageError,
@ -147,6 +149,7 @@ export default function AdminAppConfigurationClient({
simplifiedView,
isAnalyzingConfiguration,
}: AppConfiguration &
{ secret: string } &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
isAnalyzingConfiguration?: boolean
@ -373,7 +376,7 @@ export default function AdminAppConfigurationClient({
Store auth secret in environment variable:
{!hasAuthSecret &&
<div className="overflow-x-auto">
<SecretGenerator />
<SecretGenerator {...{ secret }} />
</div>}
{renderEnvVars(['AUTH_SECRET'])}
</ChecklistRow>

View File

@ -1,6 +1,7 @@
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
import { APP_CONFIGURATION } from '@/app/config';
import { testConnectionsAction } from '@/admin/actions';
import { generateAuthSecret } from '@/auth';
export default async function AdminAppConfigurationServer({
simplifiedView,
@ -9,10 +10,13 @@ export default async function AdminAppConfigurationServer({
}) {
const connectionErrors = await testConnectionsAction().catch(() => ({}));
const secret = await generateAuthSecret();
return (
<AdminAppConfigurationClient {...{
...APP_CONFIGURATION,
...connectionErrors,
secret,
simplifiedView,
}} />
);

View File

@ -22,7 +22,11 @@ export default function SelectPhotosProvider({
const { isUserSignedIn } = useAppState();
const searchParamsSelect = useClientSearchParams(PARAM_SELECT);
const searchParamsSelect = useClientSearchParams(
PARAM_SELECT,
// Only scan urls when admin is signed in
isUserSignedIn,
);
const [canCurrentPageSelectPhotos, setCanCurrentPageSelectPhotos] =
useState(false);
@ -36,9 +40,12 @@ export default function SelectPhotosProvider({
, []);
useEffect(() => {
const doesPageHavePhotoGrids = getPhotoGridElements().length > 0;
setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids);
}, [pathname, getPhotoGridElements]);
if (isUserSignedIn) {
const doesPageHavePhotoGrids = getPhotoGridElements().length > 0;
// eslint-disable-next-line react-hooks/set-state-in-effect
setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids);
}
}, [pathname, isUserSignedIn, getPhotoGridElements]);
const isSelectingPhotos = useMemo(() =>
isUserSignedIn &&
@ -66,6 +73,7 @@ export default function SelectPhotosProvider({
photoGrids[0]?.scrollIntoView({ behavior: 'smooth' });
}
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedPhotoIds([]);
}
}, [isSelectingPhotos, getPhotoGridElements]);

View File

@ -18,7 +18,6 @@ import { SWRKey } from '@/swr';
export type AppStateContextType = {
// CORE
hasLoaded?: boolean
hasLoadedWithAnimations?: boolean
invalidateSwr?: (key?: SWRKey, revalidate?: boolean) => void
nextPhotoAnimation?: AnimationConfig
@ -31,6 +30,7 @@ export type AppStateContextType = {
typeof getCountsForCategoriesCachedAction
>>
// ENVIRONMENT
timezone?: string
supportsHover?: boolean
// MODAL
isCommandKOpen?: boolean

View File

@ -54,8 +54,6 @@ export default function AppStateProvider({
const pathname = usePathname();
// CORE
const [hasLoaded, setHasLoaded] =
useState(false);
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
useState(false);
const [nextPhotoAnimation, _setNextPhotoAnimation] =
@ -80,6 +78,7 @@ export default function AppStateProvider({
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
useState(true);
// ENVIRONMENT
const [timezone, setTimezone] = useState<string>();
const supportsHover = useSupportsHover();
// MODAL
const [isCommandKOpen, setIsCommandKOpen] =
@ -118,9 +117,11 @@ export default function AppStateProvider({
useState(false);
useEffect(() => {
setHasLoaded(true);
storeTimezoneCookie();
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserEmailEager(getAuthEmailCookie());
// Capture backup timezone on client
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
if (IS_PRODUCTION) { warmRedisAction(); }
const timeout = setTimeout(() => {
setHasLoadedWithAnimations(true);
@ -152,6 +153,7 @@ export default function AppStateProvider({
} = useSWR(SWR_KEYS.GET_AUTH, getAuthAction);
useEffect(() => {
if (auth === null || authError) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserEmail(undefined);
setUserEmailEager(undefined);
clearAuthEmailCookie();
@ -222,7 +224,6 @@ export default function AppStateProvider({
<AppStateContext.Provider
value={{
// CORE
hasLoaded,
hasLoadedWithAnimations,
invalidateSwr,
nextPhotoAnimation,
@ -233,6 +234,7 @@ export default function AppStateProvider({
setShouldRespondToKeyboardCommands,
categoriesWithCounts,
// ENVIRONMENT
timezone,
supportsHover,
// MODAL
isCommandKOpen,

View File

@ -4,13 +4,17 @@ import { clsx } from 'clsx/lite';
import Container from '@/components/Container';
import Spinner from '@/components/Spinner';
import CopyButton from '@/components/CopyButton';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { generateAuthSecretAction } from '@/auth/actions';
import { BiRefresh } from 'react-icons/bi';
export default function SecretGenerator() {
export default function SecretGenerator({
secret: secretFromProps,
}: {
secret: string
}) {
const [isLoading, setIsLoading] = useState(false);
const [secret, setSecret] = useState('');
const [secret, setSecret] = useState(secretFromProps);
const getSecret = useCallback(async () => {
setIsLoading(true);
@ -19,10 +23,6 @@ export default function SecretGenerator() {
.finally(() => setIsLoading(false));
}, []);
useEffect(() => {
getSecret();
}, [getSecret]);
return (
<div className="flex items-center gap-2">
<Container className="my-1.5 inline-flex" padding="tight">

View File

@ -1,27 +1,19 @@
'use client';
import { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
import Switcher from '@/components/switcher/Switcher';
import SwitcherItem from '@/components/switcher/SwitcherItem';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { useAppText } from '@/i18n/state/client';
import { useAppState } from './AppState';
export default function ThemeSwitcher () {
const { hasLoadedWithAnimations } = useAppState();
const appText = useAppText();
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<Switcher
// Apply offset due to outline strategy
@ -30,19 +22,19 @@ export default function ThemeSwitcher () {
<SwitcherItem
icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')}
active={theme === 'system'}
active={hasLoadedWithAnimations && theme === 'system'}
tooltip={{ content: appText.theme.system }}
/>
<SwitcherItem
icon={<BiSun size={18} />}
onClick={() => setTheme('light')}
active={theme === 'light'}
active={hasLoadedWithAnimations && theme === 'light'}
tooltip={{ content: appText.theme.light }}
/>
<SwitcherItem
icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')}
active={theme === 'dark'}
active={hasLoadedWithAnimations && theme === 'dark'}
tooltip={{ content: appText.theme.dark }}
/>
</Switcher>

View File

@ -46,7 +46,7 @@ function AnimateItems({
onAnimationComplete,
}: Props) {
const {
hasLoaded,
hasLoadedWithAnimations,
nextPhotoAnimation,
getNextPhotoAnimationId,
clearNextPhotoAnimation,
@ -56,14 +56,13 @@ function AnimateItems({
const prefersReducedMotion = usePrefersReducedMotion();
const hasLoadedInitial = useRef(hasLoaded);
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
const shouldAnimate = type !== 'none' &&
!prefersReducedMotion &&
!(animateOnFirstLoadOnly && hasLoadedInitial.current);
!(animateOnFirstLoadOnly && hasLoadedWithAnimations);
const shouldStagger =
!(staggerOnFirstLoadOnly && hasLoadedInitial.current);
!(staggerOnFirstLoadOnly && hasLoadedWithAnimations);
const typeResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.type ?? type)

View File

@ -1,33 +0,0 @@
'use client';
import useIsDesktop from '@/utility/useIsDesktop';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function RedirectOnDesktop({
redirectPath,
shouldPrefetchRedirect = true,
}: {
redirectPath: string
shouldPrefetchRedirect?: boolean
}) {
const router = useRouter();
const pathname = usePathname();
const isDesktop = useIsDesktop();
useEffect(() => {
if (shouldPrefetchRedirect) {
router.prefetch(redirectPath);
}
}, [router, shouldPrefetchRedirect, redirectPath]);
useEffect(() => {
if (isDesktop && pathname !== redirectPath) {
router.push(redirectPath);
}
}, [router, isDesktop, pathname, redirectPath]);
return null;
}

View File

@ -1,8 +1,8 @@
'use client';
import { useAppState } from '@/app/AppState';
import { formatDate } from '@/utility/date';
import { clsx } from 'clsx/lite';
import { useEffect, useState } from 'react';
export default function ResponsiveDate({
date,
@ -15,13 +15,9 @@ export default function ResponsiveDate({
className?: string
titleLabel?: string
} & Parameters<typeof formatDate>[0]) {
const [timezone, setTimezone] = useState(timezoneFromProps);
const { timezone: timezoneFromState } = useAppState();
useEffect(() => {
if (!timezoneFromProps) {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [timezoneFromProps]);
const timezone = timezoneFromProps ?? timezoneFromState;
const showPlaceholder = timezone === undefined;

View File

@ -50,6 +50,7 @@ export default function SelectMenu({
useEffect(() => {
if (readOnly) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOpen(false);
}
}, [readOnly]);

View File

@ -193,6 +193,7 @@ export default function TagInput({
useEffect(() => {
if (inputText) {
if (inputText.includes(',')) {
// eslint-disable-next-line react-hooks/set-state-in-effect
addOptions(inputText.split(','));
} else {
setShouldShowMenu(true);

View File

@ -37,6 +37,7 @@ export default function ImageWithFallback({
!ref.current?.complete ||
(ref.current?.naturalWidth ?? 0) === 0
) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFadeFallbackTransition(true);
}
}, []);

View File

@ -29,6 +29,7 @@ export default function OGLoaderImage({
useEffect(() => {
if (!ref.current?.complete) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingState('loading');
}
}, [path]);

View File

@ -34,6 +34,7 @@ export default function PathLoaderButton({
}, loaderDelay);
return () => clearTimeout(timeout);
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldShowLoader(false);
}
}, [isPending, loaderDelay]);

View File

@ -5,8 +5,8 @@ import Spinner from '../Spinner';
import LinkWithIconLoader from '../LinkWithIconLoader';
import Tooltip from '../Tooltip';
const WIDTH_CLASS = 'w-[42px]';
const WIDTH_CLASS_NARROW = 'w-[36px]';
const WIDTH_CLASS = 'w-[42px]';
const WIDTH_CLASS_NARROW = 'w-[36px]';
export default function SwitcherItem({
icon,

View File

@ -17,6 +17,7 @@ export default function useTitleCaptionAiImageQuery(
const [caption, setCaption] = useState('');
useEffect(() => {
const { title, caption } = parseTitleAndCaption(text);
// eslint-disable-next-line react-hooks/set-state-in-effect
setTitle(title);
setCaption(caption);
}, [text]);

View File

@ -102,31 +102,31 @@ const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
};
export const revalidatePhotosKey = () =>
revalidateTag(KEY_PHOTOS);
revalidateTag(KEY_PHOTOS, 'max');
export const revalidateAlbumsKey = () =>
revalidateTag(KEY_ALBUMS);
revalidateTag(KEY_ALBUMS, 'max');
export const revalidateTagsKey = () =>
revalidateTag(KEY_TAGS);
revalidateTag(KEY_TAGS, 'max');
export const revalidateRecipesKey = () =>
revalidateTag(KEY_RECIPES);
revalidateTag(KEY_RECIPES, 'max');
export const revalidateCamerasKey = () =>
revalidateTag(KEY_CAMERAS);
revalidateTag(KEY_CAMERAS, 'max');
export const revalidateLensesKey = () =>
revalidateTag(KEY_LENSES);
revalidateTag(KEY_LENSES, 'max');
export const revalidateFilmsKey = () =>
revalidateTag(KEY_FILMS);
revalidateTag(KEY_FILMS, 'max');
export const revalidateFocalLengthsKey = () =>
revalidateTag(KEY_FOCAL_LENGTHS);
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
export const revalidateYearsKey = () =>
revalidateTag(KEY_YEARS);
revalidateTag(KEY_YEARS, 'max');
export const revalidateAllKeys = () => {
revalidatePhotosKey();
@ -151,7 +151,7 @@ export const revalidateAllKeysAndPaths = () => {
export const revalidatePhoto = (photoId: string) => {
// Tags
revalidateTag(photoId);
revalidateTag(photoId, 'max');
revalidateYearsKey();
revalidateCamerasKey();
revalidateLensesKey();

View File

@ -24,6 +24,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
useEffect(() => {
if (recipeTitle && hasRecipeTitleChanged && recipeData && film) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMatchingPhotosCount(undefined);
getPhotosNeedingRecipeTitleCountAction(recipeData, film, photoId)
.then(setMatchingPhotosCount);

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/set-state-in-effect */
'use client';
import {
@ -371,7 +372,7 @@ export default function PhotoForm({
blurCompatibilityLevel="none"
width={thumbnailDimensions.width}
height={thumbnailDimensions.height}
priority
preload
/>;
return (

View File

@ -31,6 +31,7 @@ export default function PlaceInput({
useEffect(() => {
if (inputTextDebounced) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsLoadingPlaces(true);
getPlaceAutoCompleteAction(inputTextDebounced)
.then(options => {

View File

@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
export default function useClientSearchParams(
paramKey: string,
enableScanning = true,
): string | undefined {
const pathname = usePathname();
@ -23,7 +24,10 @@ export default function useClientSearchParams(
};
}, [captureParam]);
useEffect(captureParam, [captureParam, pathname]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (enableScanning) { captureParam(); }
}, [pathname, captureParam, enableScanning]);
return paramValue;
};

View File

@ -17,9 +17,8 @@ export default function useHash() {
// Needed to capture non-request-initiated hash changes
const params = useSearchParams();
useEffect(() => {
storeHash();
}, [params, storeHash]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(storeHash, [params, storeHash]);
const updateWindowHash = useCallback((hash: string) => {
window.history.replaceState(null, '', `#${hash}`);

View File

@ -1,20 +0,0 @@
import { useEffect, useState } from 'react';
export default function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState<boolean>();
useEffect(() => {
const breakpointMd = getComputedStyle(document.body)
.getPropertyValue('--breakpoint-md');
const mql = window.matchMedia(`(min-width: ${breakpointMd})`);
setIsDesktop(mql.matches);
const eventHandler = (event: MediaQueryListEvent) =>
setIsDesktop(event.matches);
mql.addEventListener('change', eventHandler);
return () => mql.removeEventListener('change', eventHandler);
}, []);
return isDesktop;
};

View File

@ -5,14 +5,16 @@ export default function useSupportsHover() {
? window.matchMedia('(hover: hover)')
: undefined);
const [supportsHover, setSupportsHover] =
useState<boolean>(mqlRef.current?.matches ?? false);
const [supportsHover, setSupportsHover] = useState<boolean>(false);
useEffect(() => {
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
const mql = mqlRef.current;
mql?.addEventListener('change', listener);
return () => mql?.removeEventListener('change', listener);
if (mql) {
setSupportsHover(mql.matches);
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
mql.addEventListener('change', listener);
return () => mql?.removeEventListener('change', listener);
}
}, []);
return supportsHover;

View File

@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{