Skip to content

Improve route warming and media loading#50

Merged
geraldbahati merged 1 commit into
mainfrom
geraldbahati/boost-site-speed
Apr 24, 2026
Merged

Improve route warming and media loading#50
geraldbahati merged 1 commit into
mainfrom
geraldbahati/boost-site-speed

Conversation

@geraldbahati

@geraldbahati geraldbahati commented Apr 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Reduce initial client cost by deferring navbar overlay, analytics startup, Lenis initialization, and the homepage projects/FAQ island.
  • Add adaptive route prefetching and deduped image warming across homepage CTAs, project cards, project navigation, galleries, and video posters.
  • Scope Convex to the contact form route, add connection hints for media/Convex hosts, and keep React Doctor at 100/100.

Test Plan

  • Verified in dev (npm run dev)
  • Build passes (npm run build)
  • Lint passes (npm run lint)

Checklist

  • No TypeScript errors
  • Mobile responsive
  • Lighthouse scores maintained

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced navbar menu with animated overlay, profile image display, and interactive social links
    • Optimized image loading for galleries and project previews
  • UI Updates

    • Refreshed color palette to refined dark gray tones throughout the interface
    • Refined button and form styling
  • Performance

    • Improved resource loading efficiency
    • Enhanced scroll smoothness

@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
my-portfolio Ready Ready Preview, Comment Apr 24, 2026 0:49am

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces adaptive viewport-based prefetching infrastructure and defers non-critical initialization. New utilities (resource-warmup.ts, useAdaptivePrefetch) enable intelligent image and route preloading when components enter the viewport. Components like navbar overlay and analytics are lazy-loaded, Lenis activation is made event-driven, and navigation links migrate from next/link to AdaptiveLink. Color scheme updates from black to gray-950 for consistency.

Changes

Cohort / File(s) Summary
Adaptive Prefetch Infrastructure
hooks/use-adaptive-prefetch.ts, lib/resource-warmup.ts, components/AdaptiveLink.tsx
New hook and utilities enabling viewport-based route and image warming with network-aware gating; new ForwardRef link component integrating adaptive prefetch behavior.
Link Migrations to AdaptiveLink
app/(root)/projects/[slug]/_components/project-*.tsx, components/navbar.tsx, components/project-card.tsx, components/projects-grid.tsx, sections/bio-content.tsx, sections/contact.tsx, sections/hero-enhancements.tsx
Navigation links switched from next/link to AdaptiveLink with viewport-driven prefetching (replacing explicit prefetch props).
Deferred/Lazy Component Loading
sections/deferred-combined-projects-faq.tsx, components/PageAnalyticsRuntime.tsx, components/PageAnalytics.tsx, components/navbar.tsx
New deferred FAQ and analytics components with IntersectionObserver-triggered rendering and dynamic imports; analytics activation moved to requestIdleCallback.
Image Warming & Gallery Optimization
components/ImageWarmupSentinel.tsx, components/ui/sticky-scroll-reveal.tsx, app/(root)/projects/[slug]/_components/project-gallery.tsx, app/(root)/projects/[slug]/_components/project-video.tsx
New sentinel component for batch image preloading; existing components integrated with warmImages utility; sticky-scroll and video components use IntersectionObserver to warm poster/gallery images.
Navbar Extraction & Overlay
components/navbar-menu-overlay.tsx, components/navbar.tsx
Menu overlay markup and behavior extracted to separate client component; main navbar simplified with dynamic import and reduced code.
Lenis Activation Deferral
components/LenisProvider.tsx
Activation deferred via requestIdleCallback with event listeners (wheel, touchstart, pointerdown, keydown) triggering on-demand initialization; reduced-motion mode now destroys instance.
Color Scheme Updates
app/(root)/contact/ContactForm.tsx, app/(root)/page.tsx, sections/combined-projects-faq.tsx
Background color changed from bg-black to bg-gray-950 for button, page section, and FAQ backgrounds.
Convex & Head Configuration
app/layout.tsx, app/(root)/contact/head.tsx, app/(root)/contact/page.tsx
ConvexClientProvider removed from layout; conditional wrapping added to contact page when NEXT_PUBLIC_CONVEX_URL is present; new head component injects preconnect and dns-prefetch hints for Convex backend.
Prefetch Control Fine-Tuning
app/(root)/contact/ContactForm.tsx, sections/hero-content.tsx
Privacy link and hero contact link set to prefetch={false}.

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
Loading
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
Loading
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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #44: Adds conditional Convex initialization and contact page fallbacks; directly related to contact-page Convex wrapping and provider removal from layout.
  • PR #39: Modifies image preloading and sticky-scroll-reveal component; shares resource-warmup utilities and image warming pattern integration.
  • PR #8: Updates sections/contact.tsx with link/prefetch behavior and motion wrapper changes; same file modified for AdaptiveLink and viewport-driven prefetch.

Poem

🐰 Hopping through viewports with adaptive flair,
Warming resources before the user gets there,
Lazy-loaded whiskers, deferred with care,
Gray-950 burrows beat black's old despair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Improve route warming and media loading' directly reflects the main objectives of the PR: adding adaptive route prefetching and deduplicated image warming across multiple components.
Description check ✅ Passed The PR description covers all key changes with clear bullet points and includes the required test plan with checkboxes. However, some checklist items (Mobile responsive, Lighthouse scores) remain unchecked, which may indicate incomplete testing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch geraldbahati/boost-site-speed

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (17)
components/PageAnalytics.tsx (1)

57-65: Cleanup guard is slightly loose but harmless.

cancelIdleCallback is only invoked when it exists, but not gated on whether requestIdleCallback was the branch actually taken — it works today because idleId stays null in 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);
       }
     };

cancelIdleCallback availability is implied by supportsIdleCallback having been true when idleId was 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: Extract ProjectsFaqSkeleton to 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()'s loading, 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.tsx and 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.tsx exports the component as a default export, so the re-mapping with .then((mod) => ({ default: mod.default })) is redundant. Next.js's dynamic() 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: Duplicate ContactFormUnavailable implementations.

ContactFormUnavailable is defined in both this file (lines 112-130) and in app/(root)/contact/ContactForm.tsx (lines 65-92) with diverging copy/styles. Since page.tsx now short-circuits to its local ContactFormUnavailable when NEXT_PUBLIC_CONVEX_URL is missing, the one inside ContactForm.tsx becomes 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.current is true and subsequent activateLenis calls are effectively no-ops. However, the wheel, touchstart, pointerdown, and keydown listeners stay attached for the lifetime of the component, firing on every keystroke and scroll. The cost per call is small (an early-return inside ensureInstance plus clearIdleSetup), but the keydown listener 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() (idempotent removeEventListener makes 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 in warmedImages.

warmedImages.add(src) is called unconditionally after setting image.src. If the load fails (network error, 404, CORS), we never retry the warmup for the remainder of the session. Attaching image.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 raw src string, 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) use quality={80/90} and sizes without unoptimized or a custom loader, so they route through the optimizer.
  • project-gallery.tsx: Gallery images rendered with <Image> (e.g., line 29) similarly use the default optimizer without unoptimized.

The warmup strategy only works for:

  • Images with unoptimized prop (e.g., project-video.tsx lines 124, 151, 178)
  • Images using cloudflareLoader (e.g., optimized-image.tsx line 71 when isR2 is true)

For optimizer-routed images, either:

  1. Add unoptimized to <Image> if the image source doesn't benefit from Next.js optimization, or
  2. Replace raw warmImage() calls with <link rel="preload" as="image" imageSrcSet=... imageSizes=...> that mirrors the <Image>'s responsive parameters, or
  3. Implement a prewarming strategy that uses the configured loader (e.g., route through cloudflareLoader during 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, then stackImages sorted by order). With limit={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: images in the dep array assumes a stable reference from callers.

Currently the only consumer (project-gallery.tsx) is a server component, so images is 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 by warmedImages) 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: isSamePage breaks for href values containing query/hash.

normalizePath only strips a trailing slash, and pathname from usePathname() never contains a query string or hash. An href like /contact#form or /projects?tag=web will 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 if AdaptiveLink is 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/link to AdaptiveLink now pulls a client component (with useAdaptivePrefetch, 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 IntersectionObserver watches the same containerRef already observed by useVisibility at 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 extend useVisibility to accept a pre-warm margin) to avoid maintaining two parallel observer lifecycles on the same element.

Also worth noting: since MediaRenderer renders the <video> element with a poster attribute on mount, the browser already fetches posterUrl as 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 when MediaRenderer is 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/onMouseLeave are attached to the link, but there are no matching onFocus/onBlur handlers, so keyboard users tabbing onto the CTA won't see the scramble animation (they'll just land on unstyled/idle text). Consider wiring handleMouseEnter/handleMouseLeave to 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-triggered warmImages call is effectively a no-op.

warmImage deduplicates via the warmedImages cache (see lib/resource-warmup.ts), and the effect at lines 300–306 already warms previewImage as soon as the card becomes visible — which happens before a hover is possible (visibility is observed with rootMargin: "50px", while hover requires the card to be on-screen and under the pointer). The second call on onMouseEnter (line 323) can be removed to simplify the handler, or kept only as a fallback for the edge case where previewImage changes 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-black inconsistent with gray-950 migration.

The PR migrates several bg-black usages to bg-gray-950 for consistency (see project-cta.tsx). The focus-visible:ring-offset-black here 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) and SOCIAL_ENTER_DELAYS (5 entries) silently correspond to MENU_ITEMS.length and SOCIAL_LINKS.length. If either list is edited without updating the corresponding delay arrays, animationDelay becomes undefined s for 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 hardcodes 600 on line 59. If CSS animation durations ever change, only one of these will be updated. Consider extracting an ENTER_DURATION_MS to 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

📥 Commits

Reviewing files that changed from the base of the PR and between fe10933 and a5291bb.

📒 Files selected for processing (29)
  • app/(root)/contact/ContactForm.tsx
  • app/(root)/contact/head.tsx
  • app/(root)/contact/page.tsx
  • app/(root)/page.tsx
  • app/(root)/projects/[slug]/_components/project-cta.tsx
  • app/(root)/projects/[slug]/_components/project-gallery.tsx
  • app/(root)/projects/[slug]/_components/project-hero.tsx
  • app/(root)/projects/[slug]/_components/project-navigation.tsx
  • app/(root)/projects/[slug]/_components/project-video.tsx
  • app/layout.tsx
  • components/AdaptiveLink.tsx
  • components/ImageWarmupSentinel.tsx
  • components/LenisProvider.tsx
  • components/PageAnalytics.tsx
  • components/PageAnalyticsRuntime.tsx
  • components/navbar-menu-overlay.tsx
  • components/navbar.tsx
  • components/project-card.tsx
  • components/projects-grid.tsx
  • components/ui/sticky-scroll-reveal.tsx
  • hooks/use-adaptive-prefetch.ts
  • lib/resource-warmup.ts
  • sections/bio-content.tsx
  • sections/combined-projects-faq-wrapper.tsx
  • sections/combined-projects-faq.tsx
  • sections/contact.tsx
  • sections/deferred-combined-projects-faq.tsx
  • sections/hero-content.tsx
  • sections/hero-enhancements.tsx

Comment on lines +1 to +28
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}`} />
</>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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:


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:

  1. React DOM helpers: ReactDOM.preconnect(origin) and ReactDOM.prefetchDNS(origin) called during render
  2. Metadata API: export metadata with hints in metadata.other
  3. 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.

Comment on lines +3 to +8
import {
forwardRef,
type AnchorHTMLAttributes,
type MutableRefObject,
type Ref,
} from "react";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 5

Repository: 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:


🏁 Script executed:

cat -n components/AdaptiveLink.tsx

Repository: 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 ref directly in AdaptiveLinkProps as ref?: Ref<HTMLAnchorElement>
  • Removing the forwardRef wrapper
  • Updating the assignRef helper 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.

Comment on lines 126 to 141
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +31 to +36
const isProduction = process.env.NODE_ENV === "production";

useEffect(() => {
if (!isProduction) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@geraldbahati geraldbahati merged commit e175757 into main Apr 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant