Improve route warming and media loading#50
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces adaptive viewport-based prefetching infrastructure and defers non-critical initialization. New utilities ( Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Component as AdaptiveLink<br/>Component
participant Hook as useAdaptivePrefetch<br/>Hook
participant Warmup as warmRoute()
participant Router as router.prefetch()
participant Network as Browser Network<br/>Stack
User->>Component: Hover/Focus on Link
Component->>Hook: onMouseEnter/onFocus triggered
Hook->>Hook: Check if different route
Hook->>Warmup: Trigger warmRoute(href)
Warmup->>Warmup: Check canWarmResources()
Warmup->>Warmup: Validate href path
Warmup->>Router: Call router.prefetch(href)
Router->>Network: Request route assets
Network-->>Router: Assets preloaded
Component-->>User: Link ready for faster nav
sequenceDiagram
participant Viewport as Viewport/Scroll
participant Sentinel as ImageWarmupSentinel<br/>Component
participant Observer as IntersectionObserver
participant Warmup as warmImages()
participant Image as Image Object<br/>Browser
Viewport->>Observer: Sentinel enters viewport
Observer->>Sentinel: Intersection detected
Sentinel->>Warmup: Call warmImages([urls], limit)
Warmup->>Warmup: Filter by network/cache
Warmup->>Image: Create Image with src
Image->>Image: Request & decode async
Image-->>Warmup: Image cached in memory
Sentinel->>Observer: Disconnect observer
sequenceDiagram
participant Page as Page/Route
participant Sentinel as Deferred Component<br/>Sentinel
participant Observer as IntersectionObserver
participant Loader as Dynamic Import
participant Component as CombinedProjectsFaq<br/>Component
Page->>Observer: Mount sentinel with rootMargin
Note over Page: Render skeleton fallback
Sentinel->>Observer: Sentinel approaches viewport
Observer->>Sentinel: Intersection detected
Sentinel->>Loader: Load component dynamically
Loader->>Component: Import resolved
Component-->>Page: Render FAQ section
Sentinel->>Observer: Disconnect observer
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (17)
components/PageAnalytics.tsx (1)
57-65: Cleanup guard is slightly loose but harmless.
cancelIdleCallbackis only invoked when it exists, but not gated on whetherrequestIdleCallbackwas the branch actually taken — it works today becauseidleIdstaysnullin the fallback branch, so the guard is correct. Optional tightening for clarity:♻️ Proposed tidy-up
return () => { - if (idleId !== null && typeof window.cancelIdleCallback === "function") { + if (idleId !== null) { window.cancelIdleCallback(idleId); } - - if (timeoutId !== null) { + if (timeoutId !== null) { window.clearTimeout(timeoutId); } };
cancelIdleCallbackavailability is implied bysupportsIdleCallbackhaving been true whenidleIdwas assigned.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/PageAnalytics.tsx` around lines 57 - 65, The cleanup currently calls window.cancelIdleCallback if it exists but doesn't confirm the idle callback branch was used; tighten the guard by checking the same condition used when scheduling the idle callback (e.g., supportsIdleCallback or the path that called requestIdleCallback) before calling window.cancelIdleCallback(idleId), and keep the existing timeoutId/clearTimeout logic unchanged; refer to idleId, supportsIdleCallback, requestIdleCallback, cancelIdleCallback and timeoutId to locate the scheduling and cleanup code to mirror the original branching in the teardown.sections/deferred-combined-projects-faq.tsx (2)
11-31: ExtractProjectsFaqSkeletonto a shared module to avoid triplication.The same skeleton is defined here and in
sections/combined-projects-faq-wrapper.tsx, and then referenced three times in total (the wrapper's<Suspense fallback>,dynamic()'sloading, and the pre-intersection render below). If the visual ever changes, all three copies must be kept in sync. Consider lifting it into e.g.sections/projects-faq-skeleton.tsxand importing it from both files.♻️ Proposed extraction
New file
sections/projects-faq-skeleton.tsx:export function ProjectsFaqSkeleton() { return ( <div className="min-h-screen flex items-center justify-center"> <div className="text-center"> <div className="animate-pulse text-lg text-muted-foreground"> Loading projects... </div> </div> </div> ); }Then in this file:
-import dynamic from "next/dynamic"; -import { useEffect, useRef, useState } from "react"; -import type { Project } from "@/lib/data/projects"; +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; +import type { Project } from "@/lib/data/projects"; +import { ProjectsFaqSkeleton } from "./projects-faq-skeleton"; @@ -function ProjectsFaqSkeleton() { - return ( - <div className="min-h-screen flex items-center justify-center"> - <div className="text-center"> - <div className="animate-pulse text-lg text-muted-foreground"> - Loading projects... - </div> - </div> - </div> - ); -}And the mirror removal in
combined-projects-faq-wrapper.tsx.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sections/deferred-combined-projects-faq.tsx` around lines 11 - 31, Extract the duplicated ProjectsFaqSkeleton component into a new shared module (e.g., export function ProjectsFaqSkeleton in sections/projects-faq-skeleton.tsx) and replace the local ProjectsFaqSkeleton declaration in this file with an import of that shared component; update other usages (the CombinedProjectsFaqSection dynamic() loading option, the Suspense fallback in combined-projects-faq-wrapper.tsx, and any pre-intersection render) to import and reference the shared ProjectsFaqSkeleton so there is a single source of truth for the skeleton UI.
23-31: Simplify the dynamic import by removing the unnecessary.then()wrapper.
combined-projects-faq.tsxexports the component as a default export, so the re-mapping with.then((mod) => ({ default: mod.default }))is redundant. Next.js'sdynamic()function handles default exports automatically.✂️ Simplification
-const CombinedProjectsFaqSection = dynamic( - () => - import("./combined-projects-faq").then((mod) => ({ - default: mod.default, - })), - { - loading: () => <ProjectsFaqSkeleton />, - }, -); +const CombinedProjectsFaqSection = dynamic( + () => import("./combined-projects-faq"), + { + loading: () => <ProjectsFaqSkeleton />, + }, +);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sections/deferred-combined-projects-faq.tsx` around lines 23 - 31, The dynamic import wrapper is unnecessarily re-mapping the default export; update CombinedProjectsFaqSection to import("./combined-projects-faq") directly without the .then(...) wrapper and keep the loading option as is. Specifically, in the dynamic(...) call that creates CombinedProjectsFaqSection, remove the .then((mod) => ({ default: mod.default })) and pass the import promise (import("./combined-projects-faq")) so Next.js resolves the default export automatically while retaining loading: () => <ProjectsFaqSkeleton />.app/(root)/contact/page.tsx (1)
268-276: DuplicateContactFormUnavailableimplementations.
ContactFormUnavailableis defined in both this file (lines 112-130) and inapp/(root)/contact/ContactForm.tsx(lines 65-92) with diverging copy/styles. Sincepage.tsxnow short-circuits to its localContactFormUnavailablewhenNEXT_PUBLIC_CONVEX_URLis missing, the one insideContactForm.tsxbecomes unreachable through this page. Consider consolidating into a single shared component to keep messaging consistent.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/`(root)/contact/page.tsx around lines 268 - 276, There are two different implementations of ContactFormUnavailable (one local in page.tsx and one in ContactForm.tsx) causing duplicate, inconsistent messaging; remove the duplicate by keeping a single shared ContactFormUnavailable component (e.g., export it from ContactForm.tsx alongside ContactForm) and update the usage in page.tsx to import and render that shared component instead of the local definition, making sure to consolidate the copy and styling into that single exported component and delete the now-unused local ContactFormUnavailable.components/LenisProvider.tsx (1)
143-156: Activation listeners remain attached after first activation.Once
ensureInstance()has created Lenis,hasActivatedRef.currentistrueand subsequentactivateLeniscalls are effectively no-ops. However, thewheel,touchstart,pointerdown, andkeydownlisteners stay attached for the lifetime of the component, firing on every keystroke and scroll. The cost per call is small (an early-return insideensureInstanceplusclearIdleSetup), but thekeydownlistener in particular dispatches on every keypress in forms (e.g., the contact form), which is unnecessary after Lenis is running.Consider self-removing the listeners once activation succeeds:
Suggested approach
+ const removeActivationListeners = () => { + passiveInteractionEvents.forEach((eventName) => { + window.removeEventListener(eventName, activateLenis); + }); + window.removeEventListener("keydown", activateLenis); + }; + const activateLenis = () => { clearIdleSetup(); ensureInstance(); + if (lenisRef.current) { + removeActivationListeners(); + } };And update the cleanup to call
removeActivationListeners()(idempotentremoveEventListenermakes this safe).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/LenisProvider.tsx` around lines 143 - 156, The activation listeners (passiveInteractionEvents + "keydown") are never removed after Lenis starts; modify activateLenis so that after ensureInstance() sets hasActivatedRef.current true it calls a new removeActivationListeners() helper that removes the same events (using passiveInteractionEvents and "keydown") and also calls clearIdleSetup(), and make the component cleanup call removeActivationListeners() as well (removal is idempotent via removeEventListener). Ensure removeActivationListeners is exported/defined near passiveInteractionEvents and used both inside activateLenis on successful activation and in the effect cleanup to avoid lingering listeners.lib/resource-warmup.ts (2)
56-70: Optional: consider not caching failures inwarmedImages.
warmedImages.add(src)is called unconditionally after settingimage.src. If the load fails (network error, 404, CORS), we never retry the warmup for the remainder of the session. Attachingimage.onerror = () => warmedImages.delete(src)would allow retry on a later viewport trigger.🛠️ Proposed refactor
const image = new window.Image(); image.decoding = "async"; + image.onerror = () => { + warmedImages.delete(src); + }; image.src = src; warmedImages.add(src); return true;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/resource-warmup.ts` around lines 56 - 70, The warmImage function currently adds the src to warmedImages immediately, preventing retries after transient load failures; modify warmImage (and the Image instance created there) to only retain the src in warmedImages on successful load and remove it on error/abort by attaching image.onload = () => warmedImages.add(src) and image.onerror = image.onabort = () => warmedImages.delete(src) (or at minimum image.onerror = () => warmedImages.delete(src)), and avoid the unconditional warmedImages.add(src) call so failed loads can be retried later.
56-70: Prewarmed URLs don't match optimized Image requests.
warmImage()loads the rawsrcstring, but Next.js<Image>components with the default optimizer request/_next/image?url=<encoded>&w=<width>&q=<quality>. This cache-key mismatch means prewarming is ineffective for images using the default optimizer.In this codebase, the affected images are:
- sticky-scroll-reveal.tsx: All
<Image>instances (lines 352, 377, 447, 470) usequality={80/90}andsizeswithoutunoptimizedor a customloader, so they route through the optimizer.- project-gallery.tsx: Gallery images rendered with
<Image>(e.g., line 29) similarly use the default optimizer withoutunoptimized.The warmup strategy only works for:
- Images with
unoptimizedprop (e.g., project-video.tsx lines 124, 151, 178)- Images using
cloudflareLoader(e.g., optimized-image.tsx line 71 whenisR2is true)For optimizer-routed images, either:
- Add
unoptimizedto<Image>if the image source doesn't benefit from Next.js optimization, or- Replace raw
warmImage()calls with<link rel="preload" as="image" imageSrcSet=... imageSizes=...>that mirrors the<Image>'s responsive parameters, or- Implement a prewarming strategy that uses the configured loader (e.g., route through
cloudflareLoaderduring preload for R2 images).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/resource-warmup.ts` around lines 56 - 70, warmImage() currently constructs a raw window.Image from the provided src (and checks warmedImages/canWarmResources()), but Next.js <Image> instances that use the default optimizer are requested via /_next/image?url=<encoded>&w=...&q=..., so warming the raw src misses the optimizer cache; fix by either (A) marking affected <Image> usages in sticky-scroll-reveal.tsx and project-gallery.tsx as unoptimized when appropriate (add unoptimized prop to those <Image> components), or (B) change callers that use warmImage() to emit proper preload link elements that mirror the Next <Image> optimizer requests (generate /_next/image URL with encoded src, width(s), quality matching the <Image> sizes/quality and use <link rel="preload" as="image" imageSrcSet=... imageSizes=...>), or (C) implement warmImage() to resolve through the configured loader (e.g., cloudflareLoader path used in optimized-image.tsx when isR2) so warmedImages tracks the optimizer URL rather than the raw src; update warmedImages keys and callers accordingly (warmImage, warmedImages, canWarmResources).app/(root)/projects/[slug]/_components/project-gallery.tsx (1)
19-23: Optional: warm images in render order for better perceived load.
galleryImages = gallery.map(i => i.src)uses the incoming array's order, which doesn't necessarily match the render order (feature first, thenstackImagessorted byorder). Withlimit={2}, the two warmed URLs could end up being arbitrary stacks rather than the feature image + the first visible stack. Building the list in render order avoids surprises:🛠️ Proposed ordering
- const galleryImages = gallery.map((item) => item.src); + const galleryImages = [ + ...(featureImage ? [featureImage.src] : []), + ...stackImages.map((item) => item.src), + ];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/`(root)/projects/[slug]/_components/project-gallery.tsx around lines 19 - 23, The warmup list currently maps gallery.map(item => item.src) which can differ from the rendered order; update the ImageWarmupSentinel input so galleryImages is built in render order (feature image first, then stackImages sorted by their render order) — i.e., derive the array from the same source used to render (use the feature image src followed by stackImages sorted by order and mapped to src) and pass that to ImageWarmupSentinel (referencing galleryImages, stackImages, feature image and ImageWarmupSentinel) so limit={2} warms the feature + first visible stack.components/ImageWarmupSentinel.tsx (1)
23-61: Note:imagesin the dep array assumes a stable reference from callers.Currently the only consumer (
project-gallery.tsx) is a server component, soimagesis stable. If this gets reused from a client component that builds the array inline on each render (e.g.gallery.map(i => i.src)inside a re-rendering parent), the effect will tear down and recreate the observer on every render, which could either double-fire warmups (still deduped bywarmedImages) or miss the trigger if unmount/mount straddles the intersection.Consider memoizing in the callsite, or (defensively) joining to a stable string key:
🧷 Defensive alternative
- }, [delayMs, images, limit, rootMargin]); + const imagesKey = images.join("|"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [delayMs, imagesKey, limit, rootMargin]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/ImageWarmupSentinel.tsx` around lines 23 - 61, The effect depends on the images array reference which can be unstable and cause repeated observer teardown/recreation; either require callers to pass a stable memoized images prop or make the effect depend on a stable key derived from images (e.g., compute an imagesKey via JSON.stringify(images) or images.map(...).join('|') and use imagesKey in the useEffect dependency array) while still using the images variable inside the effect to call warmImages; update the dependency list from [delayMs, images, limit, rootMargin] to [delayMs, imagesKey, limit, rootMargin] (or document the memoization requirement) and keep sentinelRef, observer/disconnect, timeout handling and warmImages usage unchanged.hooks/use-adaptive-prefetch.ts (1)
14-35: Minor:isSamePagebreaks forhrefvalues containing query/hash.
normalizePathonly strips a trailing slash, andpathnamefromusePathname()never contains a query string or hash. Anhreflike/contact#formor/projects?tag=webwill always be treated as different from the current page, so prefetch fires even when the user is already on the target route. Not exploitable in current usages (/projects, etc.), but worth hardening ifAdaptiveLinkis adopted more broadly.🛠️ Proposed hardening
function normalizePath(path: string) { + // Strip query and hash before comparing to pathname. + const queryIndex = path.search(/[?#]/); + const pathOnly = queryIndex === -1 ? path : path.slice(0, queryIndex); - if (path.length > 1 && path.endsWith("/")) { - return path.slice(0, -1); + if (pathOnly.length > 1 && pathOnly.endsWith("/")) { + return pathOnly.slice(0, -1); } - return path; + return pathOnly; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@hooks/use-adaptive-prefetch.ts` around lines 14 - 35, The isSamePage check incorrectly compares pathname (which never contains query/hash) to href (which may include them); update normalizePath (or add a new helper) to strip any search and hash from the href before trimming trailing slashes and then use that normalized path in useAdaptivePrefetch’s isSamePage comparison; reference the normalizePath function, the useAdaptivePrefetch hook, the isSamePage variable, and usePathname/useRouter to locate and adjust the comparison so hrefs like "/contact#form" or "/projects?tag=web" are resolved to the same bare path as pathname.sections/bio-content.tsx (1)
9-15: Docstring is now stale — "zero JS cost" no longer holds.The header comment at lines 9–15 describes this as a "Server Component (zero JS cost)", but the migration from
next/linktoAdaptiveLinknow pulls a client component (withuseAdaptivePrefetch, IntersectionObserver, and event handlers) into this tree. Consider updating the docstring to reflect that the CTA introduces a small client boundary for viewport-triggered prefetching.Also applies to: 187-210
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sections/bio-content.tsx` around lines 9 - 15, The file-level docstring for BioContent is outdated: it claims "Server Component (zero JS cost)" but the CTA uses AdaptiveLink (and its hook useAdaptivePrefetch / IntersectionObserver handlers), which introduces a small client boundary; update the comment(s) around BioContent (and the other header block at the later location) to reflect that the component is primarily server-rendered but includes a client boundary for the CTA/AdaptiveLink that enables viewport-triggered prefetching and minimal client JS.app/(root)/projects/[slug]/_components/project-video.tsx (1)
198-219: Consider consolidating with the existing visibility observer.This new
IntersectionObserverwatches the samecontainerRefalready observed byuseVisibilityat line 196. They serve different purposes (300px pre-warm vs. 0.3 autoplay trigger), so two observers is defensible, but you could extract a small helper (or extenduseVisibilityto accept a pre-warm margin) to avoid maintaining two parallel observer lifecycles on the same element.Also worth noting: since
MediaRendererrenders the<video>element with aposterattribute on mount, the browser already fetchesposterUrlas part of normal video loading. The warm-up call here effectively races that same request and may be redundant — the net benefit depends on how early the 300px margin fires relative to whenMediaRendereris mounted/hydrated in the tree.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/`(root)/projects/[slug]/_components/project-video.tsx around lines 198 - 219, The new useEffect creates a second IntersectionObserver on the same containerRef to pre-warm posterUrl which duplicates existing visibility logic from useVisibility; refactor by consolidating into a single visibility observer — either extend useVisibility to accept an optional rootMargin/prewarmThreshold or extract a small helper that accepts containerRef and margin and triggers warmImages([posterUrl], 1) when intersecting — remove the extra useEffect observer and call warmImages from the unified observer (preserving the 300px margin behavior) and keep references to containerRef, posterUrl, warmImages, and useVisibility in your changes.app/(root)/projects/[slug]/_components/project-cta.tsx (1)
75-103: Scramble effect won't trigger for keyboard users.Hover-based
onMouseEnter/onMouseLeaveare attached to the link, but there are no matchingonFocus/onBlurhandlers, so keyboard users tabbing onto the CTA won't see the scramble animation (they'll just land on unstyled/idle text). Consider wiringhandleMouseEnter/handleMouseLeaveto focus/blur as well so the visual affordance matches across input modalities.♻️ Proposed fix
<AdaptiveLink href="/contact" prefetchOnViewport prefetchRootMargin="150px" onClick={() => Analytics.trackButtonClick("Projekt anfragen", "Project CTA") } className="inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onFocus={handleMouseEnter} + onBlur={handleMouseLeave} aria-label="Projekt anfragen" >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/`(root)/projects/[slug]/_components/project-cta.tsx around lines 75 - 103, The CTA only triggers scramble on pointer hover; wire keyboard focus to the same handlers by adding onFocus and onBlur (or onFocusCapture/onBlurCapture if preferred) to the AdaptiveLink so handleMouseEnter sets the hover state and handleMouseLeave clears it for keyboard users as well, ensuring the TextScramble (triggered by shouldTrigger) and the border hover styling behave identically for focus and mouse interactions; keep existing props (onClick, onMouseEnter, onMouseLeave, aria-label) and ensure the same state updates and onScrambleComplete (handleScrambleComplete) are preserved.components/project-card.tsx (1)
300-324: The hover-triggeredwarmImagescall is effectively a no-op.
warmImagededuplicates via thewarmedImagescache (seelib/resource-warmup.ts), and the effect at lines 300–306 already warmspreviewImageas soon as the card becomes visible — which happens before a hover is possible (visibility is observed withrootMargin: "50px", while hover requires the card to be on-screen and under the pointer). The second call ononMouseEnter(line 323) can be removed to simplify the handler, or kept only as a fallback for the edge case wherepreviewImagechanges identity after mount.♻️ Proposed simplification
className="block relative" - onMouseEnter={() => { - setIsHovered(true); - warmImages([previewImage], 1); - }} + onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/project-card.tsx` around lines 300 - 324, The onMouseEnter handler calls warmImages([previewImage], 1) redundantly because the useEffect tied to isVisible already warms previewImage; remove the warmImages call inside the onMouseEnter callback (in the component's onMouseEnter handler) to simplify the handler, or if you prefer to keep a fallback for previewImage identity changes, guard that call by comparing a saved warmed key (or the warmedImages cache) to previewImage before invoking warmImages; reference warmImages, previewImage, isVisible, useEffect and the onMouseEnter handler to locate the change.sections/contact.tsx (1)
91-99: Minor:ring-offset-blackinconsistent with gray-950 migration.The PR migrates several
bg-blackusages tobg-gray-950for consistency (seeproject-cta.tsx). Thefocus-visible:ring-offset-blackhere uses the old token — not incorrect, just worth aligning if the migration is meant to be uniform.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sections/contact.tsx` around lines 91 - 99, Summary: The AdaptiveLink instance in sections/contact.tsx still uses the old Tailwind token ring-offset-black instead of the migrated ring-offset-gray-950. Fix: update the className string on the AdaptiveLink (the component instance using onMouseEnter/handleMouseLeave) to replace focus-visible:ring-offset-black with focus-visible:ring-offset-gray-950 so it matches the bg-gray-950 migration and maintains consistent focus styling across the codebase.components/navbar-menu-overlay.tsx (1)
41-44: Delay arrays are tightly coupled to item list lengths.
ITEM_ENTER_DELAYS(3 entries) andSOCIAL_ENTER_DELAYS(5 entries) silently correspond toMENU_ITEMS.lengthandSOCIAL_LINKS.length. If either list is edited without updating the corresponding delay arrays,animationDelaybecomesundefined sfor the extra items, which breaks the stagger silently. Consider deriving delays from the index (e.g.,0.3 + index * 0.1) or colocating the delay inside each item tuple so they grow together.Also applies to: 126-149, 214-241
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/navbar-menu-overlay.tsx` around lines 41 - 44, The delay arrays (ITEM_ENTER_DELAYS, ITEM_EXIT_DELAYS, SOCIAL_ENTER_DELAYS, SOCIAL_EXIT_DELAYS) are brittle because they must match MENU_ITEMS and SOCIAL_LINKS lengths; update the code to compute per-item delays from the item index instead of hardcoded arrays (or move delay into each menu/social item definition) so additions/removals stay in sync. Locate where animationDelay is set for menu items and social links (references to ITEM_ENTER_DELAYS, ITEM_EXIT_DELAYS, SOCIAL_ENTER_DELAYS, SOCIAL_EXIT_DELAYS) and replace with a deterministic formula like base + index * step or read a delay property on each item in MENU_ITEMS/SOCIAL_LINKS; ensure both enter and exit delays are derived consistently to avoid undefined animationDelay values.components/navbar.tsx (1)
20-20: Mirror the enter-animation timeout with a named constant.The exit timer uses
EXIT_DURATION_MS(800ms), but the enter path hardcodes600on line 59. If CSS animation durations ever change, only one of these will be updated. Consider extracting anENTER_DURATION_MSto keep both in sync with their respective CSS animations.♻️ Proposed refactor
+const ENTER_DURATION_MS = 600; const EXIT_DURATION_MS = 800; @@ exitTimerRef.current = setTimeout(() => { setIsAnimating(false); - }, 600); + }, ENTER_DURATION_MS); return;Also applies to: 57-69
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/navbar.tsx` at line 20, The enter animation timeout is hardcoded as 600ms while the exit uses the EXIT_DURATION_MS constant (800ms); define a new constant ENTER_DURATION_MS (e.g., 600) alongside EXIT_DURATION_MS and replace the hardcoded 600 in the enter-path logic so both enter and exit timeouts are driven by named constants (refer to EXIT_DURATION_MS, ENTER_DURATION_MS and the enter timeout usage where 600 is currently hardcoded).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/`(root)/contact/head.tsx:
- Around line 1-28: Delete this unused App Router head.tsx file and move the
Convex preconnect/dns-prefetch logic into the contact page (e.g.
app/(root)/contact/page.tsx): remove the getConvexOrigin and default export Head
from this file, and in ContactPage render or emit the same hints using one of
the supported approaches (ReactDOM.preconnect/ReactDOM.prefetchDNS inside a
small ConvexResourceHints component, the Metadata API via export const
metadata.other, or by including <link> elements directly in the page/layout JSX
so React 19 hoists them). Ensure you reuse the same URL parsing logic (new
URL(process.env.NEXT_PUBLIC_CONVEX_URL).origin/hostname) and preserve
crossOrigin handling when creating the preconnect/hint calls.
In `@components/AdaptiveLink.tsx`:
- Around line 3-8: The component imports and uses the removed MutableRefObject
type and forwardRef pattern; update AdaptiveLink to React 19's ref-as-prop
pattern by removing the MutableRefObject import and the forwardRef wrapper, add
ref?: Ref<HTMLAnchorElement> to AdaptiveLinkProps so the component receives ref
as a normal prop, and update the assignRef helper to accept React's unified Ref
type (Ref<HTMLAnchorElement>) and handle callback/ref objects accordingly;
ensure all references to forwardRef and MutableRefObject are removed and the
component exports the plain AdaptiveLink function that uses the new ref prop.
In `@components/LenisProvider.tsx`:
- Around line 126-141: onChange currently destroys the instance and clears idle
setup when reduced-motion becomes active, but when the media query flips back to
no-preference the function only schedules activation (via
requestIdleCallback/setTimeout) and doesn't re-arm the interaction listeners if
you implemented "self-remove after activation"; update onChange to re-attach the
early-activation listeners when event.matches is false (the no-preference
branch) so users can trigger activateLenis immediately—use the same listener
registration logic used at mount (the functions that set up the
wheel/touch/keydown handlers), and ensure you call that re-attach before or
along with scheduling idleId/idleTimeoutId; reference onChange, destroyInstance,
hasActivatedRef, clearIdleSetup, activateLenis, supportsIdleCallback, idleId and
idleTimeoutId to locate where to add the re-attach logic.
In `@components/PageAnalytics.tsx`:
- Around line 31-36: The component currently returns early in non-production and
also skips mounting the analytics runtime via the useEffect gated by
isProduction, which prevents useAnalytics and the '[Analytics - Dev Mode]'
console output from Analytics.trackEvent from running in development; restore
mounting in dev by removing the isProduction short-circuit around the useEffect
(and similar gating at the other occurrence around lines 68-70) so useAnalytics
is called regardless of NODE_ENV, relying on Analytics.isAnalyticsEnabled() to
prevent real network calls; update the logic around isProduction, useEffect, and
useAnalytics to only suppress production-side behavior (not dev logging) while
keeping Analytics.isAnalyticsEnabled() as the final guard.
---
Nitpick comments:
In `@app/`(root)/contact/page.tsx:
- Around line 268-276: There are two different implementations of
ContactFormUnavailable (one local in page.tsx and one in ContactForm.tsx)
causing duplicate, inconsistent messaging; remove the duplicate by keeping a
single shared ContactFormUnavailable component (e.g., export it from
ContactForm.tsx alongside ContactForm) and update the usage in page.tsx to
import and render that shared component instead of the local definition, making
sure to consolidate the copy and styling into that single exported component and
delete the now-unused local ContactFormUnavailable.
In `@app/`(root)/projects/[slug]/_components/project-cta.tsx:
- Around line 75-103: The CTA only triggers scramble on pointer hover; wire
keyboard focus to the same handlers by adding onFocus and onBlur (or
onFocusCapture/onBlurCapture if preferred) to the AdaptiveLink so
handleMouseEnter sets the hover state and handleMouseLeave clears it for
keyboard users as well, ensuring the TextScramble (triggered by shouldTrigger)
and the border hover styling behave identically for focus and mouse
interactions; keep existing props (onClick, onMouseEnter, onMouseLeave,
aria-label) and ensure the same state updates and onScrambleComplete
(handleScrambleComplete) are preserved.
In `@app/`(root)/projects/[slug]/_components/project-gallery.tsx:
- Around line 19-23: The warmup list currently maps gallery.map(item =>
item.src) which can differ from the rendered order; update the
ImageWarmupSentinel input so galleryImages is built in render order (feature
image first, then stackImages sorted by their render order) — i.e., derive the
array from the same source used to render (use the feature image src followed by
stackImages sorted by order and mapped to src) and pass that to
ImageWarmupSentinel (referencing galleryImages, stackImages, feature image and
ImageWarmupSentinel) so limit={2} warms the feature + first visible stack.
In `@app/`(root)/projects/[slug]/_components/project-video.tsx:
- Around line 198-219: The new useEffect creates a second IntersectionObserver
on the same containerRef to pre-warm posterUrl which duplicates existing
visibility logic from useVisibility; refactor by consolidating into a single
visibility observer — either extend useVisibility to accept an optional
rootMargin/prewarmThreshold or extract a small helper that accepts containerRef
and margin and triggers warmImages([posterUrl], 1) when intersecting — remove
the extra useEffect observer and call warmImages from the unified observer
(preserving the 300px margin behavior) and keep references to containerRef,
posterUrl, warmImages, and useVisibility in your changes.
In `@components/ImageWarmupSentinel.tsx`:
- Around line 23-61: The effect depends on the images array reference which can
be unstable and cause repeated observer teardown/recreation; either require
callers to pass a stable memoized images prop or make the effect depend on a
stable key derived from images (e.g., compute an imagesKey via
JSON.stringify(images) or images.map(...).join('|') and use imagesKey in the
useEffect dependency array) while still using the images variable inside the
effect to call warmImages; update the dependency list from [delayMs, images,
limit, rootMargin] to [delayMs, imagesKey, limit, rootMargin] (or document the
memoization requirement) and keep sentinelRef, observer/disconnect, timeout
handling and warmImages usage unchanged.
In `@components/LenisProvider.tsx`:
- Around line 143-156: The activation listeners (passiveInteractionEvents +
"keydown") are never removed after Lenis starts; modify activateLenis so that
after ensureInstance() sets hasActivatedRef.current true it calls a new
removeActivationListeners() helper that removes the same events (using
passiveInteractionEvents and "keydown") and also calls clearIdleSetup(), and
make the component cleanup call removeActivationListeners() as well (removal is
idempotent via removeEventListener). Ensure removeActivationListeners is
exported/defined near passiveInteractionEvents and used both inside
activateLenis on successful activation and in the effect cleanup to avoid
lingering listeners.
In `@components/navbar-menu-overlay.tsx`:
- Around line 41-44: The delay arrays (ITEM_ENTER_DELAYS, ITEM_EXIT_DELAYS,
SOCIAL_ENTER_DELAYS, SOCIAL_EXIT_DELAYS) are brittle because they must match
MENU_ITEMS and SOCIAL_LINKS lengths; update the code to compute per-item delays
from the item index instead of hardcoded arrays (or move delay into each
menu/social item definition) so additions/removals stay in sync. Locate where
animationDelay is set for menu items and social links (references to
ITEM_ENTER_DELAYS, ITEM_EXIT_DELAYS, SOCIAL_ENTER_DELAYS, SOCIAL_EXIT_DELAYS)
and replace with a deterministic formula like base + index * step or read a
delay property on each item in MENU_ITEMS/SOCIAL_LINKS; ensure both enter and
exit delays are derived consistently to avoid undefined animationDelay values.
In `@components/navbar.tsx`:
- Line 20: The enter animation timeout is hardcoded as 600ms while the exit uses
the EXIT_DURATION_MS constant (800ms); define a new constant ENTER_DURATION_MS
(e.g., 600) alongside EXIT_DURATION_MS and replace the hardcoded 600 in the
enter-path logic so both enter and exit timeouts are driven by named constants
(refer to EXIT_DURATION_MS, ENTER_DURATION_MS and the enter timeout usage where
600 is currently hardcoded).
In `@components/PageAnalytics.tsx`:
- Around line 57-65: The cleanup currently calls window.cancelIdleCallback if it
exists but doesn't confirm the idle callback branch was used; tighten the guard
by checking the same condition used when scheduling the idle callback (e.g.,
supportsIdleCallback or the path that called requestIdleCallback) before calling
window.cancelIdleCallback(idleId), and keep the existing timeoutId/clearTimeout
logic unchanged; refer to idleId, supportsIdleCallback, requestIdleCallback,
cancelIdleCallback and timeoutId to locate the scheduling and cleanup code to
mirror the original branching in the teardown.
In `@components/project-card.tsx`:
- Around line 300-324: The onMouseEnter handler calls warmImages([previewImage],
1) redundantly because the useEffect tied to isVisible already warms
previewImage; remove the warmImages call inside the onMouseEnter callback (in
the component's onMouseEnter handler) to simplify the handler, or if you prefer
to keep a fallback for previewImage identity changes, guard that call by
comparing a saved warmed key (or the warmedImages cache) to previewImage before
invoking warmImages; reference warmImages, previewImage, isVisible, useEffect
and the onMouseEnter handler to locate the change.
In `@hooks/use-adaptive-prefetch.ts`:
- Around line 14-35: The isSamePage check incorrectly compares pathname (which
never contains query/hash) to href (which may include them); update
normalizePath (or add a new helper) to strip any search and hash from the href
before trimming trailing slashes and then use that normalized path in
useAdaptivePrefetch’s isSamePage comparison; reference the normalizePath
function, the useAdaptivePrefetch hook, the isSamePage variable, and
usePathname/useRouter to locate and adjust the comparison so hrefs like
"/contact#form" or "/projects?tag=web" are resolved to the same bare path as
pathname.
In `@lib/resource-warmup.ts`:
- Around line 56-70: The warmImage function currently adds the src to
warmedImages immediately, preventing retries after transient load failures;
modify warmImage (and the Image instance created there) to only retain the src
in warmedImages on successful load and remove it on error/abort by attaching
image.onload = () => warmedImages.add(src) and image.onerror = image.onabort =
() => warmedImages.delete(src) (or at minimum image.onerror = () =>
warmedImages.delete(src)), and avoid the unconditional warmedImages.add(src)
call so failed loads can be retried later.
- Around line 56-70: warmImage() currently constructs a raw window.Image from
the provided src (and checks warmedImages/canWarmResources()), but Next.js
<Image> instances that use the default optimizer are requested via
/_next/image?url=<encoded>&w=...&q=..., so warming the raw src misses the
optimizer cache; fix by either (A) marking affected <Image> usages in
sticky-scroll-reveal.tsx and project-gallery.tsx as unoptimized when appropriate
(add unoptimized prop to those <Image> components), or (B) change callers that
use warmImage() to emit proper preload link elements that mirror the Next
<Image> optimizer requests (generate /_next/image URL with encoded src,
width(s), quality matching the <Image> sizes/quality and use <link rel="preload"
as="image" imageSrcSet=... imageSizes=...>), or (C) implement warmImage() to
resolve through the configured loader (e.g., cloudflareLoader path used in
optimized-image.tsx when isR2) so warmedImages tracks the optimizer URL rather
than the raw src; update warmedImages keys and callers accordingly (warmImage,
warmedImages, canWarmResources).
In `@sections/bio-content.tsx`:
- Around line 9-15: The file-level docstring for BioContent is outdated: it
claims "Server Component (zero JS cost)" but the CTA uses AdaptiveLink (and its
hook useAdaptivePrefetch / IntersectionObserver handlers), which introduces a
small client boundary; update the comment(s) around BioContent (and the other
header block at the later location) to reflect that the component is primarily
server-rendered but includes a client boundary for the CTA/AdaptiveLink that
enables viewport-triggered prefetching and minimal client JS.
In `@sections/contact.tsx`:
- Around line 91-99: Summary: The AdaptiveLink instance in sections/contact.tsx
still uses the old Tailwind token ring-offset-black instead of the migrated
ring-offset-gray-950. Fix: update the className string on the AdaptiveLink (the
component instance using onMouseEnter/handleMouseLeave) to replace
focus-visible:ring-offset-black with focus-visible:ring-offset-gray-950 so it
matches the bg-gray-950 migration and maintains consistent focus styling across
the codebase.
In `@sections/deferred-combined-projects-faq.tsx`:
- Around line 11-31: Extract the duplicated ProjectsFaqSkeleton component into a
new shared module (e.g., export function ProjectsFaqSkeleton in
sections/projects-faq-skeleton.tsx) and replace the local ProjectsFaqSkeleton
declaration in this file with an import of that shared component; update other
usages (the CombinedProjectsFaqSection dynamic() loading option, the Suspense
fallback in combined-projects-faq-wrapper.tsx, and any pre-intersection render)
to import and reference the shared ProjectsFaqSkeleton so there is a single
source of truth for the skeleton UI.
- Around line 23-31: The dynamic import wrapper is unnecessarily re-mapping the
default export; update CombinedProjectsFaqSection to
import("./combined-projects-faq") directly without the .then(...) wrapper and
keep the loading option as is. Specifically, in the dynamic(...) call that
creates CombinedProjectsFaqSection, remove the .then((mod) => ({ default:
mod.default })) and pass the import promise (import("./combined-projects-faq"))
so Next.js resolves the default export automatically while retaining loading: ()
=> <ProjectsFaqSkeleton />.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d11cf1a5-0ecd-4087-9249-469ed84f9eed
📒 Files selected for processing (29)
app/(root)/contact/ContactForm.tsxapp/(root)/contact/head.tsxapp/(root)/contact/page.tsxapp/(root)/page.tsxapp/(root)/projects/[slug]/_components/project-cta.tsxapp/(root)/projects/[slug]/_components/project-gallery.tsxapp/(root)/projects/[slug]/_components/project-hero.tsxapp/(root)/projects/[slug]/_components/project-navigation.tsxapp/(root)/projects/[slug]/_components/project-video.tsxapp/layout.tsxcomponents/AdaptiveLink.tsxcomponents/ImageWarmupSentinel.tsxcomponents/LenisProvider.tsxcomponents/PageAnalytics.tsxcomponents/PageAnalyticsRuntime.tsxcomponents/navbar-menu-overlay.tsxcomponents/navbar.tsxcomponents/project-card.tsxcomponents/projects-grid.tsxcomponents/ui/sticky-scroll-reveal.tsxhooks/use-adaptive-prefetch.tslib/resource-warmup.tssections/bio-content.tsxsections/combined-projects-faq-wrapper.tsxsections/combined-projects-faq.tsxsections/contact.tsxsections/deferred-combined-projects-faq.tsxsections/hero-content.tsxsections/hero-enhancements.tsx
| function getConvexOrigin() { | ||
| const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; | ||
| if (!convexUrl) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| return new URL(convexUrl).origin; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export default function Head() { | ||
| const convexOrigin = getConvexOrigin(); | ||
| if (!convexOrigin) { | ||
| return null; | ||
| } | ||
|
|
||
| const convexHostname = new URL(convexOrigin).hostname; | ||
|
|
||
| return ( | ||
| <> | ||
| <link rel="preconnect" href={convexOrigin} crossOrigin="" /> | ||
| <link rel="dns-prefetch" href={`//${convexHostname}`} /> | ||
| </> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Is the head.js / head.tsx file convention supported in Next.js 16 App Router, or was it removed in favor of the Metadata API?
💡 Result:
No, the head.js / head.tsx file convention is not supported in the current Next.js 16 App Router. It was an early experimental feature introduced in Next.js 13 but removed in favor of the Metadata API (static metadata exports and dynamic generateMetadata functions).
Citations:
- 1: Remove head.js vercel/next.js#47507
- 2: https://nextjs.org/docs/14/app/building-your-application/optimizing/metadata
- 3: Head component not working vercel/next.js#53529
- 4: https://www.buttercups.tech/blog/react/next-app-router-head-a-complete-guide-for-developers
head.tsx file convention is not supported in App Router — delete this file.
The head.js/head.tsx special file was an early experimental feature in Next.js 13 and was removed in favor of the Metadata API. In Next.js 16, this file is not recognized by the framework — the default export will never be mounted into <head> for the /contact route, so none of the Convex preconnect/dns-prefetch hints will actually render.
Move these hints into app/(root)/contact/page.tsx using either:
- React DOM helpers:
ReactDOM.preconnect(origin)andReactDOM.prefetchDNS(origin)called during render - Metadata API: export
metadatawith hints inmetadata.other - Layout/page JSX: React 19 hoists
<link>elements into<head>automatically
Example using ReactDOM helpers in app/(root)/contact/page.tsx
import ReactDOM from "react-dom";
function ConvexResourceHints() {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
if (!convexUrl) return null;
let origin: string;
try {
origin = new URL(convexUrl).origin;
} catch {
return null;
}
ReactDOM.preconnect(origin, { crossOrigin: "anonymous" });
ReactDOM.prefetchDNS(origin);
return null;
}
// Render <ConvexResourceHints /> inside ContactPage()Then delete app/(root)/contact/head.tsx.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/`(root)/contact/head.tsx around lines 1 - 28, Delete this unused App
Router head.tsx file and move the Convex preconnect/dns-prefetch logic into the
contact page (e.g. app/(root)/contact/page.tsx): remove the getConvexOrigin and
default export Head from this file, and in ContactPage render or emit the same
hints using one of the supported approaches
(ReactDOM.preconnect/ReactDOM.prefetchDNS inside a small ConvexResourceHints
component, the Metadata API via export const metadata.other, or by including
<link> elements directly in the page/layout JSX so React 19 hoists them). Ensure
you reuse the same URL parsing logic (new
URL(process.env.NEXT_PUBLIC_CONVEX_URL).origin/hostname) and preserve
crossOrigin handling when creating the preconnect/hint calls.
| import { | ||
| forwardRef, | ||
| type AnchorHTMLAttributes, | ||
| type MutableRefObject, | ||
| type Ref, | ||
| } from "react"; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check installed `@types/react` version and whether MutableRefObject is still exported
fd -t f 'package.json' -d 3 --exec rg -n --no-messages '"@types/react"' {} \;
echo "---"
fd -t d '@types' node_modules 2>/dev/null | head -n 5Repository: geraldbahati/my-portfolio
Length of output: 244
🌐 Web query:
Is MutableRefObject still exported from @types/react 19 or has it been removed?
💡 Result:
No, MutableRefObject has been deprecated and is no longer separately exported in @types/react 19. In React 19 types, all refs from useRef are now mutable by default. MutableRefObject is deprecated in favor of a single RefObject type where current: T (mutable, without | null by default unless specified). useRef always returns RefObject, with convenience overloads for useRef(null) returning RefObject<T | null> and useRef(undefined) returning RefObject<T | undefined>. The distinction between mutable and readonly refs has been unified, making MutableRefObject unnecessary as a separate type.
Citations:
- 1: [react] Types for 19 DefinitelyTyped/DefinitelyTyped#64451
- 2: [react] Types for React 19 DefinitelyTyped/DefinitelyTyped#69022
- 3: [react] Make all refs mutable by default DefinitelyTyped/DefinitelyTyped#64896
- 4: Change the type of refs for react 19 juliencrn/usehooks-ts#680
- 5: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts
- 6: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/HEAD/types/react/index.d.ts
- 7: https://cdn.jsdelivr.net/npm/@types/react@19.1.11/index.d.ts
🏁 Script executed:
cat -n components/AdaptiveLink.tsxRepository: geraldbahati/my-portfolio
Length of output: 2112
Migrate to React 19 ref-as-prop pattern to remove deprecated MutableRefObject type.
The MutableRefObject type has been removed from @types/react@19.2.14. Importing it will cause a type-check failure. React 19 supports ref as a regular prop, eliminating the need for forwardRef and the associated type workaround. The refactor simplifies the component by:
- Accepting
refdirectly inAdaptiveLinkPropsasref?: Ref<HTMLAnchorElement> - Removing the
forwardRefwrapper - Updating the
assignRefhelper to work with React 19's unified ref typing
♻️ Proposed refactor
import {
- forwardRef,
type AnchorHTMLAttributes,
- type MutableRefObject,
type Ref,
} from "react";
interface AdaptiveLinkProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string;
+ ref?: Ref<HTMLAnchorElement>;
prefetchOnViewport?: boolean;
prefetchRootMargin?: string;
prefetchDelayMs?: number;
}
function assignRef<T>(ref: Ref<T | null> | undefined, value: T | null) {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
- (ref as MutableRefObject<T | null>).current = value;
+ (ref as { current: T | null }).current = value;
}
-export const AdaptiveLink = forwardRef<HTMLAnchorElement, AdaptiveLinkProps>(
- function AdaptiveLink(
- {
- href,
- onFocus,
- onMouseEnter,
- prefetchOnViewport = false,
- prefetchRootMargin,
- prefetchDelayMs,
- ...props
- },
- ref,
- ) {
+export function AdaptiveLink({
+ href,
+ onFocus,
+ onMouseEnter,
+ prefetchOnViewport = false,
+ prefetchRootMargin,
+ prefetchDelayMs,
+ ref,
+ ...props
+}: AdaptiveLinkProps) {
// ...unchanged body
- },
-);
+}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/AdaptiveLink.tsx` around lines 3 - 8, The component imports and
uses the removed MutableRefObject type and forwardRef pattern; update
AdaptiveLink to React 19's ref-as-prop pattern by removing the MutableRefObject
import and the forwardRef wrapper, add ref?: Ref<HTMLAnchorElement> to
AdaptiveLinkProps so the component receives ref as a normal prop, and update the
assignRef helper to accept React's unified Ref type (Ref<HTMLAnchorElement>) and
handle callback/ref objects accordingly; ensure all references to forwardRef and
MutableRefObject are removed and the component exports the plain AdaptiveLink
function that uses the new ref prop.
| function onChange(event: MediaQueryListEvent) { | ||
| if (event.matches) { | ||
| destroyInstance(); | ||
| hasActivatedRef.current = false; | ||
| clearIdleSetup(); | ||
| } else { | ||
| ensureInstance(); | ||
| if (supportsIdleCallback) { | ||
| idleId = window.requestIdleCallback(activateLenis, { | ||
| timeout: 1800, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| idleTimeoutId = window.setTimeout(activateLenis, 1200); | ||
| } | ||
| } |
There was a problem hiding this comment.
Reduced-motion re-entry won't re-arm interaction listeners.
When the media query flips back from reduce to no-preference, activation is re-scheduled via requestIdleCallback/setTimeout, but the early-activation listeners were already attached once (at effect mount) and remain. If you adopt the "self-remove after activation" suggestion above, note that on exiting reduced-motion you will also need to re-attach the interaction listeners so users can trigger activation immediately on the next wheel/touch/keydown rather than waiting up to 1800 ms for the idle callback. If you keep the listeners attached permanently (current behavior), this branch is fine.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/LenisProvider.tsx` around lines 126 - 141, onChange currently
destroys the instance and clears idle setup when reduced-motion becomes active,
but when the media query flips back to no-preference the function only schedules
activation (via requestIdleCallback/setTimeout) and doesn't re-arm the
interaction listeners if you implemented "self-remove after activation"; update
onChange to re-attach the early-activation listeners when event.matches is false
(the no-preference branch) so users can trigger activateLenis immediately—use
the same listener registration logic used at mount (the functions that set up
the wheel/touch/keydown handlers), and ensure you call that re-attach before or
along with scheduling idleId/idleTimeoutId; reference onChange, destroyInstance,
hasActivatedRef, clearIdleSetup, activateLenis, supportsIdleCallback, idleId and
idleTimeoutId to locate where to add the re-attach logic.
| const isProduction = process.env.NODE_ENV === "production"; | ||
|
|
||
| useEffect(() => { | ||
| if (!isProduction) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Analytics dev-mode logging is now suppressed.
With the early return null in non-production and the useEffect short-circuiting when !isProduction, useAnalytics is never mounted in development. That silently disables the [Analytics - Dev Mode] console output emitted by Analytics.trackEvent (see lib/analytics.ts:47-74), which was previously useful for verifying tracking calls locally.
If the intent is purely to reduce client cost in production, consider still mounting the runtime in dev (the gating in Analytics.isAnalyticsEnabled() already prevents real network tracking). If this is intentional, feel free to ignore.
Also applies to: 68-70
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/PageAnalytics.tsx` around lines 31 - 36, The component currently
returns early in non-production and also skips mounting the analytics runtime
via the useEffect gated by isProduction, which prevents useAnalytics and the
'[Analytics - Dev Mode]' console output from Analytics.trackEvent from running
in development; restore mounting in dev by removing the isProduction
short-circuit around the useEffect (and similar gating at the other occurrence
around lines 68-70) so useAnalytics is called regardless of NODE_ENV, relying on
Analytics.isAnalyticsEnabled() to prevent real network calls; update the logic
around isProduction, useEffect, and useAnalytics to only suppress
production-side behavior (not dev logging) while keeping
Analytics.isAnalyticsEnabled() as the final guard.
Summary
Test Plan
npm run dev)npm run build)npm run lint)Checklist
Summary by CodeRabbit
Release Notes
New Features
UI Updates
Performance