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, '@stylistic': stylistic,
}, },
rules: { rules: {
// Temporarily disable during Next.js 16 migration
'react-hooks/refs': 'off', 'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'warn',
'@next/next/no-img-element': 'off', '@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-require-imports': 'off',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,19 @@
'use client'; 'use client';
import { useState, useEffect } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import Switcher from '@/components/switcher/Switcher'; import Switcher from '@/components/switcher/Switcher';
import SwitcherItem from '@/components/switcher/SwitcherItem'; import SwitcherItem from '@/components/switcher/SwitcherItem';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import { useAppState } from './AppState';
export default function ThemeSwitcher () { export default function ThemeSwitcher () {
const { hasLoadedWithAnimations } = useAppState();
const appText = useAppText(); const appText = useAppText();
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme(); 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 ( return (
<Switcher <Switcher
// Apply offset due to outline strategy // Apply offset due to outline strategy
@ -30,19 +22,19 @@ export default function ThemeSwitcher () {
<SwitcherItem <SwitcherItem
icon={<BiDesktop size={16} />} icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')} onClick={() => setTheme('system')}
active={theme === 'system'} active={hasLoadedWithAnimations && theme === 'system'}
tooltip={{ content: appText.theme.system }} tooltip={{ content: appText.theme.system }}
/> />
<SwitcherItem <SwitcherItem
icon={<BiSun size={18} />} icon={<BiSun size={18} />}
onClick={() => setTheme('light')} onClick={() => setTheme('light')}
active={theme === 'light'} active={hasLoadedWithAnimations && theme === 'light'}
tooltip={{ content: appText.theme.light }} tooltip={{ content: appText.theme.light }}
/> />
<SwitcherItem <SwitcherItem
icon={<BiMoon size={16} />} icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')} onClick={() => setTheme('dark')}
active={theme === 'dark'} active={hasLoadedWithAnimations && theme === 'dark'}
tooltip={{ content: appText.theme.dark }} tooltip={{ content: appText.theme.dark }}
/> />
</Switcher> </Switcher>

View File

@ -46,7 +46,7 @@ function AnimateItems({
onAnimationComplete, onAnimationComplete,
}: Props) { }: Props) {
const { const {
hasLoaded, hasLoadedWithAnimations,
nextPhotoAnimation, nextPhotoAnimation,
getNextPhotoAnimationId, getNextPhotoAnimationId,
clearNextPhotoAnimation, clearNextPhotoAnimation,
@ -56,14 +56,13 @@ function AnimateItems({
const prefersReducedMotion = usePrefersReducedMotion(); const prefersReducedMotion = usePrefersReducedMotion();
const hasLoadedInitial = useRef(hasLoaded);
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation); const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
const shouldAnimate = type !== 'none' && const shouldAnimate = type !== 'none' &&
!prefersReducedMotion && !prefersReducedMotion &&
!(animateOnFirstLoadOnly && hasLoadedInitial.current); !(animateOnFirstLoadOnly && hasLoadedWithAnimations);
const shouldStagger = const shouldStagger =
!(staggerOnFirstLoadOnly && hasLoadedInitial.current); !(staggerOnFirstLoadOnly && hasLoadedWithAnimations);
const typeResolved = animateFromAppState const typeResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.type ?? type) ? (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'; 'use client';
import { useAppState } from '@/app/AppState';
import { formatDate } from '@/utility/date'; import { formatDate } from '@/utility/date';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useEffect, useState } from 'react';
export default function ResponsiveDate({ export default function ResponsiveDate({
date, date,
@ -15,13 +15,9 @@ export default function ResponsiveDate({
className?: string className?: string
titleLabel?: string titleLabel?: string
} & Parameters<typeof formatDate>[0]) { } & Parameters<typeof formatDate>[0]) {
const [timezone, setTimezone] = useState(timezoneFromProps); const { timezone: timezoneFromState } = useAppState();
useEffect(() => { const timezone = timezoneFromProps ?? timezoneFromState;
if (!timezoneFromProps) {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [timezoneFromProps]);
const showPlaceholder = timezone === undefined; const showPlaceholder = timezone === undefined;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,8 @@ export default function useHash() {
// Needed to capture non-request-initiated hash changes // Needed to capture non-request-initiated hash changes
const params = useSearchParams(); const params = useSearchParams();
useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect
storeHash(); useEffect(storeHash, [params, storeHash]);
}, [params, storeHash]);
const updateWindowHash = useCallback((hash: string) => { const updateWindowHash = useCallback((hash: string) => {
window.history.replaceState(null, '', `#${hash}`); 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)') ? window.matchMedia('(hover: hover)')
: undefined); : undefined);
const [supportsHover, setSupportsHover] = const [supportsHover, setSupportsHover] = useState<boolean>(false);
useState<boolean>(mqlRef.current?.matches ?? false);
useEffect(() => { useEffect(() => {
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
const mql = mqlRef.current; const mql = mqlRef.current;
mql?.addEventListener('change', listener); if (mql) {
return () => mql?.removeEventListener('change', listener); setSupportsHover(mql.matches);
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
mql.addEventListener('change', listener);
return () => mql?.removeEventListener('change', listener);
}
}, []); }, []);
return supportsHover; return supportsHover;

View File

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