Skip to content

Conversation

@pranavjana
Copy link
Contributor

job-hunting

Live Demo: https://job-huntboard.vercel.app

Overview

Live Demo: https://job-huntboard.vercel.app

Job Hunter is a comprehensive platform that automates the job search process using Mino's browser automation API and OpenRouter's AI models. It handles everything from resume parsing and optimized job board search generation to parallel scraping and intelligent job matching.

Mino API Integration

This use case demonstrates Mino API usage for browser automation.

Tech Stack

  • Next.js (TypeScript)
  • Mino API
  • AI

Contributor: Pranav Janakiraman (@pranavjana)

- ## Demo
- Live demo: https://job-huntboard.vercel.app
- Contributor: Pranav Janakiraman (@pranavjana)
@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete new Next.js job-hunting application. It establishes a full-stack project structure with configuration files, API routes for AI-powered job analysis and resume parsing, a comprehensive UI component library built on Radix UI primitives, local storage-based data persistence, integration with external scraping and AI APIs, and a multi-step user workflow. The app enables resume upload, profile creation, automated job board scraping, AI-driven job matching, application tracking via Kanban board, and cover letter generation.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Dashboard
    participant AI API
    participant Mino API
    participant Storage

    User->>Dashboard: Start job search
    Dashboard->>AI API: Generate search URLs from profile
    AI API-->>Dashboard: Return board-specific URLs
    Dashboard->>Mino API: Scrape jobs from multiple boards (parallel)
    Mino API-->>Dashboard: Stream job listings per board
    Dashboard->>AI API: Batch analyze job matches
    AI API-->>Dashboard: Return match scores & explanations
    Dashboard->>Storage: Persist jobs & matches
    Storage-->>Dashboard: Confirmation
    Dashboard-->>User: Display matched jobs with scores
    User->>Dashboard: Select job for details
    Dashboard->>AI API: Generate cover letter
    AI API-->>Dashboard: Return generated letter
    Dashboard-->>User: Display job details with cover letter
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add job-hunting - Mino Use Case' accurately and concisely summarizes the main changeset, which introduces a complete Job Hunter application as a Mino API use case.
Description check ✅ Passed The description is directly related to the changeset, providing context about the Job Hunter project, its purpose (automating job search), key features, tech stack, and contribution details.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@job-hunting/app/api/ai/cover-letter/route.ts`:
- Around line 36-56: Validate and sanitize the job inputs before building the
prompt passed to generateText: ensure job.requirements is an array (use
Array.isArray(job.requirements) and coerce non-array values into an array or
ignore them) before calling .join, and truncate/limit job.description to a safe
max length (e.g., slice to a fixed character/token limit) to avoid unbounded
prompt size; apply these checks where the prompt string is constructed
(reference the generateText call and the variables job.requirements and
job.description) so the prompt interpolation uses the validated/trimmed values.

In `@job-hunting/components/dashboard.tsx`:
- Around line 474-478: The iframe rendering the live stream (iframe with
src={currentStreamingUrl} in dashboard.tsx) lacks a sandbox attribute and thus
risks XSS/clickjacking; add an appropriate sandbox attribute to the iframe
element (e.g., sandbox with only the necessary permissions) and combine it with
explicit allow attributes (like allow="autoplay; fullscreen") as needed for
streaming functionality, minimizing granted flags (avoid allow-top-navigation
and broad allowances) so the iframe runs with least privilege while preserving
required features.
- Line 78: The AbortController stored in abortControllerRef is not
aborted/cleared on component unmount, risking memory leaks and state updates on
unmounted components; add a useEffect cleanup that on unmount checks
abortControllerRef.current, calls abort() if present, and sets
abortControllerRef.current = null so any in-progress scan (the logic that
creates/uses abortControllerRef) is cancelled and the ref is cleared; reference
abortControllerRef and the scan invocation (the function that assigns
abortControllerRef.current) when implementing the cleanup.

In `@job-hunting/components/jobs/job-detail-dialog.tsx`:
- Around line 60-64: When the job prop changes the component doesn't reset local
state (coverLetter, isGenerating, copied, showCoverLetter), causing stale data
to appear; add a useEffect that depends on job and inside call
setCoverLetter(""), setIsGenerating(false), setCopied(false) and
setShowCoverLetter(false) so the dialog resets whenever the job prop changes
(reference the existing state hooks coverLetter/setCoverLetter,
isGenerating/setIsGenerating, copied/setCopied,
showCoverLetter/setShowCoverLetter).

In `@job-hunting/components/jobs/scraping-grid.tsx`:
- Around line 69-104: The handler passed to setBoardScans currently increments
setCompletedCount for every terminal update and never fills resultsRef, so fix
by: when updating inside setBoardScans (in the function that takes boardName and
update) only call setCompletedCount((c)=>c+1) if the previous status was not a
terminal status and update.status is terminal (check existing.status !==
"complete" && existing.status !== "error" before incrementing); populate
resultsRef.current either incrementally (update resultsRef.current[boardName] =
updated.jobsFound/details when update provides jobsFound) or compute
resultsRef.current = Array.from(newMap.values()) right after setBoardScans
finishes (before calling setIsRunning(false) and onComplete) so
onComplete(resultsRef.current) receives actual results; keep the onStreamingUrl
notification as-is and continue using abortControllerRef.current.signal.

In `@job-hunting/components/onboarding/onboarding-flow.tsx`:
- Around line 31-68: The parsed resume-to-profile mapping is duplicated; remove
the second object literal and call setParsedData(parsedProfile) instead of
reconstructing the object. Ensure parsedProfile uses consistent null coalescing
for phone (use phone ?? undefined) so both setResume and setParsedData get the
same parsedProfile object and semantics; keep parsedProfile, setResume, and
setParsedData as the referenced symbols to locate where to change.

In `@job-hunting/components/onboarding/resume-upload.tsx`:
- Around line 77-110: In processResume, ensure the progressInterval created
before awaiting parseResume is always cleared on failure: move or add
clearInterval(progressInterval) into the catch block (before calling
setState/setError) so the interval stops if parseResume throws; also consider
clearing it in a finally block to guarantee cleanup regardless of success or
error, keeping references to progressInterval, parseResume, clearInterval,
setState, setProgress, and setError to locate the code.

In `@job-hunting/components/ui/form.tsx`:
- Around line 45-66: The guard in useFormField is after fieldContext is
dereferenced, causing a crash if used outside <FormField>; move the null-check
for fieldContext to the top of useFormField (before any access to
fieldContext.name or passing it to useFormState/getFieldState) and throw the
same error if missing so subsequent lines (getFieldState, useFormState, forming
ids) only run when fieldContext is defined.

In `@job-hunting/components/ui/progress.tsx`:
- Around line 8-26: The Progress component destructures value but doesn't pass
it into ProgressPrimitive.Root; update the Progress function to forward the
value prop to ProgressPrimitive.Root (the component defined in this file as
Progress and the Radix element ProgressPrimitive.Root) so Radix can set
aria-valuenow and manage internal state—ensure you add value={value} to the Root
while keeping the existing props and indicator transform logic intact.

In `@job-hunting/lib/ai/client.ts`:
- Around line 129-152: In batchAnalyzeJobs the progress callback and fallback
IDs use the batch start index `i`, causing duplicate IDs and incorrect progress
for items in the same batch; change the code inside the batch.map to compute a
per-job index (e.g., compute `const idx = i + batchIndex` using the map second
arg or track `i + j`) and use that `idx` for onProgress (onProgress?.(idx + 1,
jobs.length)) and for the fallback ID (job.id || `job-${idx}`) so each job gets
a unique index/ID when job.id is missing while leaving analyzeJobMatch and the
error result structure unchanged.

In `@job-hunting/lib/hooks/use-local-storage.ts`:
- Around line 3-75: The bug is that setValue closes over storedValue and misses
rapid functional updates, and storage events with newValue === null (deletions)
are ignored. Fix setValue in useLocalStorage to use the functional updater form:
call setStoredValue(prev => { const valueToStore = value instanceof Function ?
value(prev) : value; return valueToStore; }) and compute valueToStore inside
that updater; remove storedValue from the useCallback dependency list (rely only
on key). When persisting, if valueToStore is null or undefined call
window.localStorage.removeItem(key) and dispatch a StorageEvent with newValue:
null; otherwise setItem(key, JSON.stringify(valueToStore)) and dispatch with the
JSON string. In the storage listener (handleStorageChange) handle e.newValue ===
null by calling setStoredValue(initialValue) (and still parse e.newValue when
present). Ensure window existence checks remain for SSR.
🟡 Minor comments (13)
job-hunting/README.md-7-7 (1)

7-7: Replace the bare URL with a markdown link (MD034).
Line 7 should use a link to satisfy markdownlint.

Suggested fix
-**Live Demo:** https://job-huntboard.vercel.app
+**Live Demo:** [Job Huntboard](https://job-huntboard.vercel.app)
job-hunting/README.md-3-19 (1)

3-19: Remove or rename the duplicate “Demo” heading (MD024).
Line 15 duplicates the earlier “## Demo” section; consolidate to a single heading.

Suggested fix (remove the placeholder section)
----
-
-## Demo
-
-*[Demo video/screenshot to be added]*
-
----
job-hunting/components/ui/card.tsx-32-45 (1)

32-45: Type mismatch between ref type and rendered element.

CardTitle declares the ref type as HTMLParagraphElement but renders an <h3> heading element. This could cause TypeScript errors or runtime confusion when consumers try to use ref methods specific to paragraph elements.

🔧 Proposed fix
 const CardTitle = React.forwardRef<
-  HTMLParagraphElement,
+  HTMLHeadingElement,
   React.HTMLAttributes<HTMLHeadingElement>
 >(({ className, ...props }, ref) => (
job-hunting/components/ui/kanban-board.tsx-214-219 (1)

214-219: Hardcoded due date display.

The due date is hardcoded as "Jan 15" instead of being formatted from task.dueDate. This means all tasks will display the same date regardless of their actual due date.

🔧 Proposed fix
 {task.dueDate && (
   <div className="flex items-center gap-1">
     <Calendar className="w-4 h-4" />
-    <span className="text-xs font-medium">Jan 15</span>
+    <span className="text-xs font-medium">
+      {new Date(task.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+    </span>
   </div>
 )}
job-hunting/app/api/scrape/route.ts-9-22 (1)

9-22: Validate searchUrl as a real http(s) URL before calling Mino API.
Currently only presence is checked; invalid or non-http(s) schemes should be rejected early to prevent upstream errors and unnecessary API calls.

🔧 Proposed fix
     if (!searchUrl) {
       return NextResponse.json(
         { error: "Search URL is required" },
         { status: 400 }
       );
     }
+
+    let parsedUrl: URL;
+    try {
+      parsedUrl = new URL(searchUrl);
+    } catch {
+      return NextResponse.json(
+        { error: "Search URL must be a valid http(s) URL" },
+        { status: 400 }
+      );
+    }
+    if (!["http:", "https:"].includes(parsedUrl.protocol)) {
+      return NextResponse.json(
+        { error: "Search URL must use http or https" },
+        { status: 400 }
+      );
+    }
job-hunting/components/jobs/job-card.tsx-211-214 (1)

211-214: Add noopener and noreferrer for security.

When opening external URLs with window.open, add security attributes to prevent potential tabnabbing attacks and avoid leaking referrer information.

🔒 Proposed fix
             onClick={(e) => {
               e.stopPropagation();
-              window.open(job.fullUrl, "_blank");
+              window.open(job.fullUrl, "_blank", "noopener,noreferrer");
             }}
job-hunting/components/jobs/job-detail-dialog.tsx-344-364 (1)

344-364: Profile match percentages are derived from matchScore with arbitrary multipliers.

The "Skills Match", "Experience Level", and "Title Alignment" percentages are calculated by multiplying matchScore by fixed factors (1.1, 0.95, 1.05). These appear to be placeholder values that don't reflect actual analysis, which could mislead users.

Consider either:

  1. Adding a comment clarifying these are approximations
  2. Deriving these from actual AI analysis data if available
  3. Removing this section until real data is available
job-hunting/components/jobs/job-detail-dialog.tsx-84-88 (1)

84-88: navigator.clipboard.writeText can fail silently.

The clipboard API may throw if permissions are denied or the page isn't focused. The current implementation doesn't handle potential errors.

🔧 Proposed fix
  const handleCopy = async () => {
-   await navigator.clipboard.writeText(coverLetter);
-   setCopied(true);
-   setTimeout(() => setCopied(false), 2000);
+   try {
+     await navigator.clipboard.writeText(coverLetter);
+     setCopied(true);
+     setTimeout(() => setCopied(false), 2000);
+   } catch (error) {
+     console.error("Failed to copy:", error);
+     // Optionally show a toast or fallback
+   }
  };
job-hunting/components/dashboard.tsx-233-236 (1)

233-236: Swallowed error - no logging or context for debugging.

The catch block discards the error details, making it difficult to debug failures. Consider logging the error or including it in the toast message.

🔧 Proposed fix
    } catch (error) {
-     toast.error("Scanning failed");
+     const message = error instanceof Error ? error.message : "Unknown error";
+     console.error("Scan failed:", error);
+     toast.error(`Scanning failed: ${message}`);
      setIsScanning(false);
    }
job-hunting/lib/types.ts-163-165 (1)

163-165: DEFAULT_PROFILE uses new Date().toISOString() at module load time.

These timestamps are evaluated once when the module loads, not when the default is used. All profiles created using this default will share the same createdAt and updatedAt timestamp from app startup.

🔧 Proposed fix

Use a factory function instead:

-export const DEFAULT_PROFILE: UserProfile = {
+export const createDefaultProfile = (): UserProfile => ({
  fullName: "",
  email: "",
  location: "",
  currentTitle: "",
  yearsExperience: 0,
  skills: [],
  industries: [],
  education: "",
  preferredTitles: [],
  seniorityLevel: "mid",
  summary: "",
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
-};
+});

+// For backward compatibility, if needed
+export const DEFAULT_PROFILE = createDefaultProfile();

Then use createDefaultProfile() where fresh timestamps are needed.

job-hunting/components/ui/command.tsx-45-59 (1)

45-59: DialogHeader with sr-only is placed outside DialogContent.

The DialogHeader is rendered as a sibling to DialogContent, but Radix UI's Dialog expects DialogTitle and DialogDescription to be descendants of DialogContent for proper ARIA association. This may cause accessibility warnings or broken screen reader announcements.

🔧 Proposed fix

Move the header inside DialogContent:

  return (
    <Dialog {...props}>
-     <DialogHeader className="sr-only">
-       <DialogTitle>{title}</DialogTitle>
-       <DialogDescription>{description}</DialogDescription>
-     </DialogHeader>
      <DialogContent
        className={cn("overflow-hidden p-0", className)}
        showCloseButton={showCloseButton}
      >
+       <DialogHeader className="sr-only">
+         <DialogTitle>{title}</DialogTitle>
+         <DialogDescription>{description}</DialogDescription>
+       </DialogHeader>
        <Command className="...">
          {children}
        </Command>
      </DialogContent>
    </Dialog>
  )
job-hunting/components/jobs/job-feed.tsx-199-249 (1)

199-249: Filter pill buttons lack proper accessibility attributes.

The filter buttons are implemented as plain <button> elements without ARIA attributes to indicate their selected/toggle state. This makes it difficult for screen reader users to understand the current filter selection.

🔧 Proposed fix
            <button
+             role="tab"
+             aria-selected={filterTab === "all"}
              className={cn(
                "px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-2",
                filterTab === "all"
                  ? "bg-deep-teal text-white"
                  : "text-muted-foreground hover:text-foreground hover:bg-muted"
              )}
              onClick={() => setFilterTab("all")}
            >

Apply similar changes to the other filter buttons and add role="tablist" to the container div at line 198.

job-hunting/lib/mino-client.ts-57-63 (1)

57-63: Incomplete buffer handling - remaining data after stream ends is discarded.

After the read loop completes, any remaining content in buffer (data after the last newline) is never processed. If the stream ends without a trailing newline, the final event could be lost.

🔧 Proposed fix

Process remaining buffer after the loop:

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() ?? "";

      for (const line of lines) {
        // ... process line
      }
    }

+   // Process any remaining data in buffer
+   if (buffer.trim()) {
+     const event = parseSSELine(buffer);
+     if (event) {
+       events.push(event);
+       if (isCompleteEvent(event)) {
+         return {
+           success: true,
+           result: event.resultJson,
+           streamingUrl,
+           events,
+         };
+       }
+     }
+   }

    return {
      success: false,
      error: "Stream ended without completion event",
🧹 Nitpick comments (27)
job-hunting/.gitignore (1)

33-34: Consider refining the .env* pattern to allow template files.

The current .env* pattern will ignore all files starting with .env, including .env.example or .env.template files that are commonly committed to repositories as templates for other developers to understand required environment variables.

♻️ Proposed refinement to allow .env.example files
 # env files (can opt-in for committing if needed)
-.env*
+.env
+.env.local
+.env.*.local
+!.env.example
+!.env.template

This approach:

  • Explicitly ignores common local environment files
  • Allows .env.example and .env.template to be committed as documentation
  • Maintains security by ignoring actual environment files with secrets
job-hunting/components/ui/sheet.tsx (1)

9-29: Preserve Radix ref support by forwarding refs in wrapper components.
These wrappers currently swallow refs, which breaks common Radix usage (focus management, measurements). Recommend React.forwardRef for components that accept refs (Trigger/Close/Overlay/Content/Title/Description).

♻️ Example pattern (apply similarly to other wrappers)
-function SheetTrigger({
-  ...props
-}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
-  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
-}
+const SheetTrigger = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
+>(({ ...props }, ref) => (
+  <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />
+))
+SheetTrigger.displayName = SheetPrimitive.Trigger.displayName
job-hunting/lib/utils.ts (2)

14-16: Use substring instead of deprecated substr.

The substr method is deprecated. Use substring or slice instead for better compatibility.

Suggested fix
 export function generateId(): string {
-  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+  return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
 }

68-80: Consider edge case with zero salary values.

If min or max is explicitly 0, the current check !min && !max will return "Not specified" since 0 is falsy. While unlikely for real salary data, consider using explicit undefined/null checks if zero is a valid value.

Suggested fix if zero is a valid value
 export function formatSalary(min?: number, max?: number): string {
-  if (!min && !max) return "Not specified";
+  if (min === undefined && max === undefined) return "Not specified";
+  if (min == null && max == null) return "Not specified";
job-hunting/components/ui/input.tsx (1)

1-17: Consider forwarding refs for form integrations.
Without forwardRef, consumers can’t reliably focus or register the input in form libraries. A small refactor keeps the API stable and improves ergonomics.

♻️ Suggested refactor
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
-  return (
-    <input
-      type={type}
-      data-slot="input"
-      className={cn(
-        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
-        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
-        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
-        className
-      )}
-      {...props}
-    />
-  )
-}
+const Input = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<"input">>(
+  ({ className, type, ...props }, ref) => (
+    <input
+      ref={ref}
+      type={type}
+      data-slot="input"
+      className={cn(
+        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+        className
+      )}
+      {...props}
+    />
+  )
+)
+Input.displayName = "Input"
job-hunting/components/jobs/job-tracker-kanban.tsx (2)

11-11: Unused import.

ExternalLink is imported but never used in this component.

🧹 Proposed fix
 import {
   GripVertical,
   Building2,
   MapPin,
   Calendar,
-  ExternalLink,
   Wifi,
 } from "lucide-react";

100-104: Remove duplicate utility function.

getMatchScoreColor is already exported from @/lib/utils (lines 49-53 in that file) and is available for import. The local definition here is identical and should be removed to avoid duplication.

♻️ Proposed fix

Update the import at line 14:

-import { cn, formatRelativeDate } from "@/lib/utils";
+import { cn, formatRelativeDate, getMatchScoreColor } from "@/lib/utils";

Then remove the local function definition (lines 100-104).

job-hunting/components/ui/kanban-board.tsx (2)

124-142: Add error handling for JSON parsing.

JSON.parse can throw if the dragged data is malformed or if a non-task element is dropped. Consider wrapping in a try-catch to prevent runtime errors.

🛡️ Proposed fix
 const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
   e.preventDefault();
-  const data = JSON.parse(e.dataTransfer.getData('text/plain'));
-  const { task, sourceColumnId } = data;
+  let data;
+  try {
+    data = JSON.parse(e.dataTransfer.getData('text/plain'));
+  } catch {
+    return;
+  }
+  const { task, sourceColumnId } = data;
+  if (!task || !sourceColumnId) return;

   if (sourceColumnId === targetColumnId) return;

171-173: Add accessible label to the button.

The "+" button lacks an accessible label, making it unclear for screen reader users what action it performs.

♿ Proposed fix
-<button className="p-1 rounded-full bg-white/30 dark:bg-neutral-800/30 hover:bg-white/50 dark:hover:bg-neutral-700/50 transition-colors">
+<button 
+  aria-label={`Add task to ${column.title}`}
+  className="p-1 rounded-full bg-white/30 dark:bg-neutral-800/30 hover:bg-white/50 dark:hover:bg-neutral-700/50 transition-colors"
+>
   <Plus className="w-4 h-4 text-neutral-700 dark:text-neutral-300" />
 </button>
job-hunting/components/ui/button.tsx (2)

1-5: Missing "use client" directive.

This component imports from @radix-ui/react-slot which uses React context and client-side features. For consistency with other UI components in this PR (like select.tsx, label.tsx, dialog.tsx) and to ensure proper client-side rendering, add the directive.

Proposed fix
+"use client"
+
 import * as React from "react"
 import { Slot } from "@radix-ui/react-slot"
 import { cva, type VariantProps } from "class-variance-authority"

39-60: Consider setting a default type="button" to prevent accidental form submissions.

When a <button> element is inside a <form> without an explicit type, it defaults to type="submit", which can cause unintended form submissions.

Proposed fix
 function Button({
   className,
   variant = "default",
   size = "default",
   asChild = false,
+  type = "button",
   ...props
 }: React.ComponentProps<"button"> &
   VariantProps<typeof buttonVariants> & {
     asChild?: boolean
   }) {
   const Comp = asChild ? Slot : "button"

   return (
     <Comp
       data-slot="button"
       data-variant={variant}
       data-size={size}
       className={cn(buttonVariants({ variant, size, className }))}
+      type={asChild ? undefined : type}
       {...props}
     />
   )
 }
job-hunting/components/ui/dialog.tsx (1)

9-31: data-slot attributes on non-DOM-rendering primitives have no effect.

DialogPrimitive.Root, DialogPrimitive.Trigger, DialogPrimitive.Portal, and DialogPrimitive.Close don't render DOM elements themselves—they render their children or manage context. The data-slot attributes won't appear in the DOM. This is harmless but inconsistent with the intent.

For DialogTrigger and DialogClose, consider passing data-slot only when they render actual DOM (or rely on the child element). For Root and Portal, the attribute can be removed.

job-hunting/app/page.tsx (1)

3-15: Drop useMemo for showOnboarding; a simple boolean is enough.
This is a cheap derivation, so memoization adds overhead and an extra import.

♻️ Proposed refactor
-import { useMemo } from "react";
+import { useMemo } from "react";
@@
-  const showOnboarding = useMemo(() => {
-    return isClient && !profile;
-  }, [isClient, profile]);
+  const showOnboarding = isClient && !profile;
job-hunting/app/api/ai/parse-resume/route.ts (1)

1-33: Deduplicate extractJSON across AI routes.
This helper is duplicated here (Lines 21‑33) and in job-hunting/app/api/ai/match-jobs/route.ts (Lines 15‑27), and is also referenced in job-hunting/app/api/ai/generate-urls/route.ts. Centralizing it avoids drift and keeps fixes consistent.

♻️ Proposed refactor (this file)
-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
+import { extractJSON } from "@/lib/utils";
@@
-function extractJSON(text: string): string {
-  // Remove markdown code blocks if present
-  const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
-  if (codeBlockMatch) {
-    return codeBlockMatch[1].trim();
-  }
-  // Try to find JSON object directly
-  const jsonMatch = text.match(/\{[\s\S]*\}/);
-  if (jsonMatch) {
-    return jsonMatch[0];
-  }
-  return text;
-}
// job-hunting/lib/utils.ts
export function extractJSON(text: string): string {
  const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (codeBlockMatch) return codeBlockMatch[1].trim();

  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (jsonMatch) return jsonMatch[0];

  return text;
}
job-hunting/package.json (1)

9-9: Lint script may need file patterns.

The eslint command without arguments relies on ESLint's flat config to determine which files to lint. Ensure your ESLint configuration includes appropriate file patterns, or consider adding explicit paths like "lint": "eslint ." for clarity.

job-hunting/app/api/ai/generate-urls/route.ts (2)

6-13: Consider adding URL validation to the schema.

The searchUrl field accepts any string. Consider using z.string().url() to ensure the AI returns valid URLs, which would catch malformed URLs early.

🔧 Suggested schema enhancement
 const searchUrlsSchema = z.object({
   urls: z.array(
     z.object({
       boardName: z.string(),
-      searchUrl: z.string(),
+      searchUrl: z.string().url(),
     })
   ),
 });

113-117: Unused error variable in catch block.

The caught error is not used, which loses diagnostic information. Consider logging the error for debugging purposes while still returning a generic message to the client.

🔧 Proposed fix
   } catch (error) {
+    console.error("Failed to generate search URLs:", error);
     return NextResponse.json(
       { error: "Failed to generate search URLs" },
       { status: 500 }
     );
   }
job-hunting/components/jobs/job-card.tsx (1)

41-51: Consider hoisting helper functions outside the component.

getMatchScoreColor and getMatchScoreBg are pure functions that don't depend on component state or props. Moving them outside the component avoids recreating them on every render.

♻️ Suggested refactor
+const getMatchScoreColor = (score: number) => {
+  if (score >= 80) return "text-match-excellent";
+  if (score >= 50) return "text-match-good";
+  return "text-match-poor";
+};
+
+const getMatchScoreBg = (score: number) => {
+  if (score >= 80) return "bg-match-excellent/10 border-match-excellent/20";
+  if (score >= 50) return "bg-match-good/10 border-match-good/20";
+  return "bg-match-poor/10 border-match-poor/20";
+};
+
 export function JobCard({
   job,
   savedJob,
   onSave,
   onUnsave,
   onClick,
   index = 0,
 }: JobCardProps) {
   const isSaved = !!savedJob;
   const isNew = job.postedDate ? isNewJob(job.postedDate) : false;
-
-  const getMatchScoreColor = (score: number) => {
-    if (score >= 80) return "text-match-excellent";
-    if (score >= 50) return "text-match-good";
-    return "text-match-poor";
-  };
-
-  const getMatchScoreBg = (score: number) => {
-    if (score >= 80) return "bg-match-excellent/10 border-match-excellent/20";
-    if (score >= 50) return "bg-match-good/10 border-match-good/20";
-    return "bg-match-poor/10 border-match-poor/20";
-  };
job-hunting/components/jobs/kanban-board.tsx (3)

140-143: Consider using useDroppable instead of useSortable for columns.

Columns are drop targets, not sortable items. Using useSortable works because it internally uses useDroppable, but useDroppable is more semantically correct and avoids unnecessary sortable overhead.

♻️ Suggested change
+import { useDroppable } from "@dnd-kit/core";
-import {
-  SortableContext,
-  sortableKeyboardCoordinates,
-  verticalListSortingStrategy,
-  useSortable,
-} from "@dnd-kit/sortable";
+import {
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+  useSortable,
+} from "@dnd-kit/sortable";

// In KanbanColumn:
-  const { setNodeRef, isOver } = useSortable({
-    id: status,
-    data: { type: "column" },
-  });
+  const { setNodeRef, isOver } = useDroppable({
+    id: status,
+    data: { type: "column" },
+  });

278-296: Remove unused savedJob prop from KanbanCardDragPreview.

The savedJob parameter is destructured but never used in the component.

♻️ Proposed fix
-function KanbanCardDragPreview({
-  job,
-  savedJob,
-}: {
-  job: Job;
-  savedJob: SavedJob;
-}) {
+function KanbanCardDragPreview({ job }: { job: Job }) {
   return (
     <Card className="w-64 shadow-xl rotate-3">

Also update the call site at line 124:

-          <KanbanCardDragPreview job={activeJob} savedJob={activeSavedJob} />
+          <KanbanCardDragPreview job={activeJob} />

71-72: Deduplicate getJobForSavedJob helper.

This helper is defined identically in both KanbanBoard (line 71) and KanbanColumn (line 145). Consider defining it once outside or passing the lookup result directly.

♻️ Option: Pass job lookup from parent
 interface KanbanColumnProps {
   status: ApplicationStatus;
   savedJobs: SavedJob[];
-  jobs: Job[];
+  getJobForSavedJob: (savedJob: SavedJob) => Job | undefined;
   onJobClick: (job: Job) => void;
 }

-function KanbanColumn({ status, savedJobs, jobs, onJobClick }: KanbanColumnProps) {
+function KanbanColumn({ status, savedJobs, getJobForSavedJob, onJobClick }: KanbanColumnProps) {
   const { label, color, bgColor } = APPLICATION_STATUS_CONFIG[status];
-
-  const getJobForSavedJob = (savedJob: SavedJob) =>
-    jobs.find((j) => j.id === savedJob.jobId);

Also applies to: 145-146

job-hunting/components/dashboard.tsx (1)

343-391: Duplicate Tabs component creates disconnected state.

The Tabs component is instantiated twice (lines 346 and 396) with the same activeTab state. While this works, it's unconventional and could cause confusion. The first Tabs only renders TabsList, and the second renders TabsContent. Consider restructuring for clarity.

♻️ Suggested restructure

Wrap both the TabsList and TabsContent elements within a single Tabs component to make the relationship clearer:

-      {/* Tabs at the top */}
-      <div className="border-b bg-background sticky top-[53px] z-10">
-        <div className="max-w-7xl mx-auto px-4">
-          <Tabs value={activeTab} onValueChange={setActiveTab}>
-            <TabsList className="h-10 bg-transparent border-none p-0 gap-0">
-              {/* ... triggers ... */}
-            </TabsList>
-          </Tabs>
-        </div>
-      </div>
-
-      {/* Main content */}
-      <main className="max-w-7xl mx-auto px-4 py-6">
-        <Tabs value={activeTab} onValueChange={setActiveTab}>
-          {/* ... content ... */}
-        </Tabs>
-      </main>
+      <Tabs value={activeTab} onValueChange={setActiveTab}>
+        {/* Tabs at the top */}
+        <div className="border-b bg-background sticky top-[53px] z-10">
+          <div className="max-w-7xl mx-auto px-4">
+            <TabsList className="h-10 bg-transparent border-none p-0 gap-0">
+              {/* ... triggers ... */}
+            </TabsList>
+          </div>
+        </div>
+
+        {/* Main content */}
+        <main className="max-w-7xl mx-auto px-4 py-6">
+          {/* ... content ... */}
+        </main>
+      </Tabs>
job-hunting/components/jobs/job-feed.tsx (1)

140-144: Local getMatchScoreColor duplicates utility function from @/lib/utils.

The relevant code snippets show that getMatchScoreColor exists in job-hunting/lib/utils.ts. This local version has slightly different return values (includes background class), but the duplication could lead to inconsistencies.

♻️ Suggested approach

Either import and extend the utility, or rename this local function to clarify its distinct purpose:

- const getMatchScoreColor = (score: number) => {
+ const getMatchScoreBadgeClass = (score: number) => {
    if (score >= 80) return "text-match-excellent bg-match-excellent/10";
    if (score >= 50) return "text-match-good bg-match-good/10";
    return "text-match-poor bg-match-poor/10";
  };
job-hunting/components/jobs/job-detail-dialog.tsx (1)

102-117: Full-screen modal without focus trap may cause accessibility issues.

The modal renders as a full-screen overlay using Framer Motion but doesn't implement focus trapping. Users navigating with keyboard may tab outside the visible modal content.

Consider using a focus trap library or the Dialog primitive from Radix UI (which handles focus management) to ensure keyboard users remain within the modal while it's open.

job-hunting/lib/types.ts (1)

140-148: Consider documenting localStorage key usage for privacy compliance.

The storage keys store user profile data including email and phone. For GDPR/CCPA compliance, ensure the application:

  1. Informs users about local storage usage
  2. Provides a way to clear this data
  3. Doesn't sync this to external services without consent

This is informational - the types themselves are fine, but the consuming code should handle PII appropriately.

job-hunting/lib/mino-client.ts (2)

166-179: JSON parsing inside callback could throw and disrupt the flow.

While parseJsonString has a try-catch, if the outer code throws during processing, the entire board scrape could fail silently. The nested structure makes error boundaries unclear.

Consider moving parseJsonString outside the callback as a module-level helper for better testability and clearer error boundaries.


264-277: Filter at line 277 may discard valid jobs with empty fullUrl.

The filter requires job.title && job.company && job.fullUrl to be truthy. Jobs without a URL would be silently discarded. Consider logging or handling these cases if the URL might legitimately be missing from some job boards.

♻️ Consider fallback handling
    .filter((job) => {
+     if (!job.fullUrl) {
+       console.warn(`Job "${job.title}" from ${sourceBoard} missing URL, skipping`);
+     }
      return job.title && job.company && job.fullUrl;
    });

Comment on lines +36 to +56
const { text } = await generateText({
model,
system: `You are an expert cover letter writer who creates compelling, personalized letters.
Write naturally and professionally, avoiding generic phrases.
Be specific about why this candidate is a great fit for this role.
Never use placeholder text - use actual company and candidate names.`,
prompt: `Write a compelling cover letter for this job application:

JOB DETAILS:
- Company: ${job.company}
- Title: ${job.title}
- Location: ${job.location}
- Description: ${job.description}
${job.requirements ? `- Requirements: ${job.requirements.join(", ")}` : ""}

CANDIDATE PROFILE:
- Name: ${profile.fullName}
- Current title: ${profile.currentTitle}
- Years of experience: ${profile.yearsExperience}
- Key skills: ${profile.skills?.slice(0, 8).join(", ") || ""}
- Summary: ${profile.summary}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

minimax/minimax-m2.1 context length limit OpenRouter

💡 Result:

On OpenRouter, minimax/minimax-m2.1 has a context length limit of 196,608 tokens (i.e., the maximum total tokens the model can handle in a single request/conversation window on OpenRouter). [1][2]

For comparison, MiniMax’s own API docs list 204,800 tokens for MiniMax-M2.1, so OpenRouter’s routed offering appears slightly lower. [3]

Sources: OpenRouter model page / API quickstart [1][2], MiniMax docs [3].


🏁 Script executed:

# Find the full route file to understand context and data types
find . -type f -name "route.ts" | grep -i "cover-letter"

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 119


🏁 Script executed:

# Search for where the model is defined
rg -i "minimax|model" --type ts --type tsx -B 3 -A 3 | head -100

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 100


🏁 Script executed:

# Check the job and profile type definitions
rg -i "interface|type.*job|type.*profile" --type ts --type tsx -B 2 -A 8 | head -150

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 100


🏁 Script executed:

cat -n ./job-hunting/app/api/ai/cover-letter/route.ts

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 2855


🏁 Script executed:

# Search for job and profile interface/type definitions
rg "interface.*job|type.*job|interface.*profile|type.*profile" --type ts -B 2 -A 10

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 5645


🏁 Script executed:

# Check how job and profile objects are passed in
rg "job\.|profile\." ./job-hunting/app/api/ai/cover-letter/route.ts -B 2 -A 2

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 545


Guard prompt inputs: validate job.requirements is an array and cap job description length.

The code assumes job.requirements is an array and calls .join() without type checking—this will throw if the field is a string or other non-array type from the external job API. Additionally, unbounded job descriptions from external sources can inflate token usage and model latency. Validate and bound both inputs before constructing the prompt.

🔧 Proposed fix
-    const { text } = await generateText({
+    const description =
+      typeof job.description === "string"
+        ? job.description.slice(0, 1500)
+        : "not provided";
+    const requirements = Array.isArray(job.requirements)
+      ? job.requirements.join(", ")
+      : "";
+
+    const { text } = await generateText({
       model,
       system: `You are an expert cover letter writer who creates compelling, personalized letters.
 Write naturally and professionally, avoiding generic phrases.
 Be specific about why this candidate is a great fit for this role.
 Never use placeholder text - use actual company and candidate names.`,
       prompt: `Write a compelling cover letter for this job application:
 
 JOB DETAILS:
 - Company: ${job.company}
 - Title: ${job.title}
 - Location: ${job.location}
- - Description: ${job.description}
-${job.requirements ? `- Requirements: ${job.requirements.join(", ")}` : ""}
+ - Description: ${description}
+${requirements ? `- Requirements: ${requirements}` : ""}
🤖 Prompt for AI Agents
In `@job-hunting/app/api/ai/cover-letter/route.ts` around lines 36 - 56, Validate
and sanitize the job inputs before building the prompt passed to generateText:
ensure job.requirements is an array (use Array.isArray(job.requirements) and
coerce non-array values into an array or ignore them) before calling .join, and
truncate/limit job.description to a safe max length (e.g., slice to a fixed
character/token limit) to avoid unbounded prompt size; apply these checks where
the prompt string is constructed (reference the generateText call and the
variables job.requirements and job.description) so the prompt interpolation uses
the validated/trimmed values.

const [boardScans, setBoardScans] = useState<Map<string, JobBoardScan>>(new Map());
const [selectedBoard, setSelectedBoard] = useState<string | null>(null);
const [currentStreamingUrl, setCurrentStreamingUrl] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

AbortController is not cleaned up on component unmount.

The abortControllerRef is created during scan but never cleaned up if the component unmounts while a scan is in progress. This can lead to memory leaks and attempts to update unmounted component state.

🔧 Proposed fix

Add cleanup in a useEffect:

  const abortControllerRef = useRef<AbortController | null>(null);

+ // Cleanup abort controller on unmount
+ useEffect(() => {
+   return () => {
+     abortControllerRef.current?.abort();
+   };
+ }, []);
🤖 Prompt for AI Agents
In `@job-hunting/components/dashboard.tsx` at line 78, The AbortController stored
in abortControllerRef is not aborted/cleared on component unmount, risking
memory leaks and state updates on unmounted components; add a useEffect cleanup
that on unmount checks abortControllerRef.current, calls abort() if present, and
sets abortControllerRef.current = null so any in-progress scan (the logic that
creates/uses abortControllerRef) is cancelled and the ref is cleared; reference
abortControllerRef and the scan invocation (the function that assigns
abortControllerRef.current) when implementing the cleanup.

Comment on lines +474 to +478
<iframe
src={currentStreamingUrl}
className="flex-1 w-full"
title="Live browser preview"
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Iframe without sandbox attribute poses a security risk.

Embedding external streaming URLs in an iframe without the sandbox attribute could expose the application to XSS or clickjacking attacks if the streaming service is compromised.

🔧 Proposed fix
                        <iframe
                          src={currentStreamingUrl}
                          className="flex-1 w-full"
                          title="Live browser preview"
+                         sandbox="allow-scripts allow-same-origin"
+                         referrerPolicy="no-referrer"
                        />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<iframe
src={currentStreamingUrl}
className="flex-1 w-full"
title="Live browser preview"
/>
<iframe
src={currentStreamingUrl}
className="flex-1 w-full"
title="Live browser preview"
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
/>
🤖 Prompt for AI Agents
In `@job-hunting/components/dashboard.tsx` around lines 474 - 478, The iframe
rendering the live stream (iframe with src={currentStreamingUrl} in
dashboard.tsx) lacks a sandbox attribute and thus risks XSS/clickjacking; add an
appropriate sandbox attribute to the iframe element (e.g., sandbox with only the
necessary permissions) and combine it with explicit allow attributes (like
allow="autoplay; fullscreen") as needed for streaming functionality, minimizing
granted flags (avoid allow-top-navigation and broad allowances) so the iframe
runs with least privilege while preserving required features.

Comment on lines +60 to +64
const [coverLetter, setCoverLetter] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [showCoverLetter, setShowCoverLetter] = useState(false);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

State is not reset when the job prop changes.

When a user opens a different job's detail dialog, the previous cover letter, generation state, and visibility remain. This leads to stale data being displayed for the new job.

🔧 Proposed fix

Add a useEffect to reset state when the job changes:

+ import { useState, useEffect } from "react";

  // ... inside component
  const [coverLetter, setCoverLetter] = useState("");
  const [isGenerating, setIsGenerating] = useState(false);
  const [copied, setCopied] = useState(false);
  const [showCoverLetter, setShowCoverLetter] = useState(false);

+ // Reset state when job changes
+ useEffect(() => {
+   setCoverLetter("");
+   setIsGenerating(false);
+   setCopied(false);
+   setShowCoverLetter(false);
+ }, [job?.id]);
🤖 Prompt for AI Agents
In `@job-hunting/components/jobs/job-detail-dialog.tsx` around lines 60 - 64, When
the job prop changes the component doesn't reset local state (coverLetter,
isGenerating, copied, showCoverLetter), causing stale data to appear; add a
useEffect that depends on job and inside call setCoverLetter(""),
setIsGenerating(false), setCopied(false) and setShowCoverLetter(false) so the
dialog resets whenever the job prop changes (reference the existing state hooks
coverLetter/setCoverLetter, isGenerating/setIsGenerating, copied/setCopied,
showCoverLetter/setShowCoverLetter).

Comment on lines +69 to +104
(boardName, update) => {
setBoardScans((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(boardName);
if (existing) {
const updated: JobBoardScan = {
...existing,
status: update.status,
steps: update.step
? [...existing.steps, update.step].slice(-5)
: existing.steps,
jobsFound: update.jobsFound ?? existing.jobsFound,
error: update.error,
streamingUrl: update.streamingUrl ?? existing.streamingUrl,
};
newMap.set(boardName, updated);

// Notify parent of streaming URL
if (update.streamingUrl && onStreamingUrl) {
onStreamingUrl(update.streamingUrl);
}

// Track completion
if (update.status === "complete" || update.status === "error") {
setCompletedCount((c) => c + 1);
}
}
return newMap;
});
},
abortControllerRef.current.signal
);

setIsRunning(false);
onComplete(resultsRef.current);
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent overcounting and return actual results.

completedCount increments on every terminal update (can exceed total), and resultsRef is never populated, so onComplete receives empty results.

🐛 Suggested fix
         (boardName, update) => {
           setBoardScans((prev) => {
             const newMap = new Map(prev);
             const existing = newMap.get(boardName);
             if (existing) {
+              const wasTerminal =
+                existing.status === "complete" || existing.status === "error";
               const updated: JobBoardScan = {
                 ...existing,
                 status: update.status,
                 steps: update.step
                   ? [...existing.steps, update.step].slice(-5)
                   : existing.steps,
                 jobsFound: update.jobsFound ?? existing.jobsFound,
                 error: update.error,
                 streamingUrl: update.streamingUrl ?? existing.streamingUrl,
               };
               newMap.set(boardName, updated);
 
+              if (Array.isArray(update.jobs)) {
+                resultsRef.current.set(boardName, update.jobs);
+              }
+
               // Notify parent of streaming URL
               if (update.streamingUrl && onStreamingUrl) {
                 onStreamingUrl(update.streamingUrl);
               }
 
               // Track completion
-              if (update.status === "complete" || update.status === "error") {
-                setCompletedCount((c) => c + 1);
-              }
+              const isTerminal =
+                update.status === "complete" || update.status === "error";
+              if (!wasTerminal && isTerminal) {
+                setCompletedCount((c) => c + 1);
+              }
             }
             return newMap;
           });
         },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(boardName, update) => {
setBoardScans((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(boardName);
if (existing) {
const updated: JobBoardScan = {
...existing,
status: update.status,
steps: update.step
? [...existing.steps, update.step].slice(-5)
: existing.steps,
jobsFound: update.jobsFound ?? existing.jobsFound,
error: update.error,
streamingUrl: update.streamingUrl ?? existing.streamingUrl,
};
newMap.set(boardName, updated);
// Notify parent of streaming URL
if (update.streamingUrl && onStreamingUrl) {
onStreamingUrl(update.streamingUrl);
}
// Track completion
if (update.status === "complete" || update.status === "error") {
setCompletedCount((c) => c + 1);
}
}
return newMap;
});
},
abortControllerRef.current.signal
);
setIsRunning(false);
onComplete(resultsRef.current);
};
(boardName, update) => {
setBoardScans((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(boardName);
if (existing) {
const wasTerminal =
existing.status === "complete" || existing.status === "error";
const updated: JobBoardScan = {
...existing,
status: update.status,
steps: update.step
? [...existing.steps, update.step].slice(-5)
: existing.steps,
jobsFound: update.jobsFound ?? existing.jobsFound,
error: update.error,
streamingUrl: update.streamingUrl ?? existing.streamingUrl,
};
newMap.set(boardName, updated);
if (Array.isArray(update.jobs)) {
resultsRef.current.set(boardName, update.jobs);
}
// Notify parent of streaming URL
if (update.streamingUrl && onStreamingUrl) {
onStreamingUrl(update.streamingUrl);
}
// Track completion
const isTerminal =
update.status === "complete" || update.status === "error";
if (!wasTerminal && isTerminal) {
setCompletedCount((c) => c + 1);
}
}
return newMap;
});
},
abortControllerRef.current.signal
);
setIsRunning(false);
onComplete(resultsRef.current);
};
🤖 Prompt for AI Agents
In `@job-hunting/components/jobs/scraping-grid.tsx` around lines 69 - 104, The
handler passed to setBoardScans currently increments setCompletedCount for every
terminal update and never fills resultsRef, so fix by: when updating inside
setBoardScans (in the function that takes boardName and update) only call
setCompletedCount((c)=>c+1) if the previous status was not a terminal status and
update.status is terminal (check existing.status !== "complete" &&
existing.status !== "error" before incrementing); populate resultsRef.current
either incrementally (update resultsRef.current[boardName] =
updated.jobsFound/details when update provides jobsFound) or compute
resultsRef.current = Array.from(newMap.values()) right after setBoardScans
finishes (before calling setIsRunning(false) and onComplete) so
onComplete(resultsRef.current) receives actual results; keep the onStreamingUrl
notification as-is and continue using abortControllerRef.current.signal.

Comment on lines +77 to +110
const processResume = async (text: string, fileName?: string) => {
setState("parsing");
setProgress(40);
setStatusMessage("Analyzing resume with AI...");

try {
// Simulate progress during AI parsing
const progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + 10, 90));
}, 1000);

setProgress(50);
setStatusMessage("Extracting skills and experience...");

const parsedData = await parseResume(text);

clearInterval(progressInterval);
setProgress(100);
setStatusMessage("Resume parsed successfully!");
setState("success");

// Small delay to show success state
setTimeout(() => {
onParsed(parsedData, text, fileName);
}, 500);
} catch (err) {
setState("error");
setError(
err instanceof Error
? err.message
: "Failed to parse resume. Please try again."
);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clear the progress interval on errors.

If parseResume throws, the interval keeps firing, so progress continues after failure and can leak state updates.

🐛 Suggested fix
   const processResume = async (text: string, fileName?: string) => {
     setState("parsing");
     setProgress(40);
     setStatusMessage("Analyzing resume with AI...");
 
+    let progressInterval: ReturnType<typeof setInterval> | undefined;
     try {
       // Simulate progress during AI parsing
-      const progressInterval = setInterval(() => {
+      progressInterval = setInterval(() => {
         setProgress((p) => Math.min(p + 10, 90));
       }, 1000);
 
       setProgress(50);
       setStatusMessage("Extracting skills and experience...");
 
       const parsedData = await parseResume(text);
 
-      clearInterval(progressInterval);
       setProgress(100);
       setStatusMessage("Resume parsed successfully!");
       setState("success");
@@
     } catch (err) {
       setState("error");
       setError(
         err instanceof Error
           ? err.message
           : "Failed to parse resume. Please try again."
       );
+    } finally {
+      if (progressInterval) clearInterval(progressInterval);
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const processResume = async (text: string, fileName?: string) => {
setState("parsing");
setProgress(40);
setStatusMessage("Analyzing resume with AI...");
try {
// Simulate progress during AI parsing
const progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + 10, 90));
}, 1000);
setProgress(50);
setStatusMessage("Extracting skills and experience...");
const parsedData = await parseResume(text);
clearInterval(progressInterval);
setProgress(100);
setStatusMessage("Resume parsed successfully!");
setState("success");
// Small delay to show success state
setTimeout(() => {
onParsed(parsedData, text, fileName);
}, 500);
} catch (err) {
setState("error");
setError(
err instanceof Error
? err.message
: "Failed to parse resume. Please try again."
);
}
};
const processResume = async (text: string, fileName?: string) => {
setState("parsing");
setProgress(40);
setStatusMessage("Analyzing resume with AI...");
let progressInterval: ReturnType<typeof setInterval> | undefined;
try {
// Simulate progress during AI parsing
progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + 10, 90));
}, 1000);
setProgress(50);
setStatusMessage("Extracting skills and experience...");
const parsedData = await parseResume(text);
setProgress(100);
setStatusMessage("Resume parsed successfully!");
setState("success");
// Small delay to show success state
setTimeout(() => {
onParsed(parsedData, text, fileName);
}, 500);
} catch (err) {
setState("error");
setError(
err instanceof Error
? err.message
: "Failed to parse resume. Please try again."
);
} finally {
if (progressInterval) clearInterval(progressInterval);
}
};
🤖 Prompt for AI Agents
In `@job-hunting/components/onboarding/resume-upload.tsx` around lines 77 - 110,
In processResume, ensure the progressInterval created before awaiting
parseResume is always cleared on failure: move or add
clearInterval(progressInterval) into the catch block (before calling
setState/setError) so the interval stops if parseResume throws; also consider
clearing it in a finally block to guarantee cleanup regardless of success or
error, keeping references to progressInterval, parseResume, clearInterval,
setState, setProgress, and setError to locate the code.

Comment on lines +45 to +66
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)

if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}

const { id } = itemContext

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard check is placed after the guarded value is used.

The check if (!fieldContext) at line 52 occurs after fieldContext.name is already accessed on lines 49-50. If useFormField is called outside of a <FormField>, this will throw a runtime error before reaching the helpful error message.

🔧 Proposed fix
 const useFormField = () => {
   const fieldContext = React.useContext(FormFieldContext)
   const itemContext = React.useContext(FormItemContext)
+
+  if (!fieldContext.name) {
+    throw new Error("useFormField should be used within <FormField>")
+  }
+
   const { getFieldState } = useFormContext()
   const formState = useFormState({ name: fieldContext.name })
   const fieldState = getFieldState(fieldContext.name, formState)

-  if (!fieldContext) {
-    throw new Error("useFormField should be used within <FormField>")
-  }
-
   const { id } = itemContext
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
🤖 Prompt for AI Agents
In `@job-hunting/components/ui/form.tsx` around lines 45 - 66, The guard in
useFormField is after fieldContext is dereferenced, causing a crash if used
outside <FormField>; move the null-check for fieldContext to the top of
useFormField (before any access to fieldContext.name or passing it to
useFormState/getFieldState) and throw the same error if missing so subsequent
lines (getFieldState, useFormState, forming ids) only run when fieldContext is
defined.

Comment on lines +8 to +26
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
Copy link

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

🏁 Script executed:

#!/bin/bash
# First, let's find and read the progress.tsx file
find . -name "progress.tsx" -type f | head -20

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Let's also check the repository structure
ls -la job-hunting/components/ui/ 2>/dev/null || ls -la components/ui/ 2>/dev/null || find . -path "*/components/ui/progress.tsx" -type f

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1584


🏁 Script executed:

#!/bin/bash
# Search for the progress component file
fd "progress.tsx" -type f

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 243


🏁 Script executed:

#!/bin/bash
# Read the complete progress.tsx file
cat -n ./job-hunting/components/ui/progress.tsx

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1029


🏁 Script executed:

#!/bin/bash
# Search for how Progress component is used in the codebase
rg "Progress" -A 2 -B 2 --type tsx --type ts | head -100

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 100


🏁 Script executed:

#!/bin/bash
# Check if there are any tests or examples using Progress
find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "Progress" | grep -v node_modules | head -10

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 382


🏁 Script executed:

#!/bin/bash
# Look for imports of ProgressPrimitive to understand the API
rg "ProgressPrimitive" -A 3 -B 3

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1852


🏁 Script executed:

#!/bin/bash
# Let's check how the Progress component is used
rg "Progress" job-hunting/components/ -A 5 -B 2

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 15167


🏁 Script executed:

#!/bin/bash
# Check package.json to understand Radix UI version
cat job-hunting/package.json | grep -A 5 "react-progress" || cat package.json | grep -A 5 "react-progress"

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 321


Pass value to the Radix Root to ensure aria-valuenow and state management are set correctly.

value is destructured but not forwarded to ProgressPrimitive.Root, so the Radix component won't set accessibility attributes (aria-valuenow) or maintain its internal state. Although the indicator transform uses value, it must also be passed directly to the Root component.

✅ Minimal fix
     <ProgressPrimitive.Root
       data-slot="progress"
+      value={value}
       className={cn(
         "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
         className
       )}
       {...props}
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
value={value}
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
🤖 Prompt for AI Agents
In `@job-hunting/components/ui/progress.tsx` around lines 8 - 26, The Progress
component destructures value but doesn't pass it into ProgressPrimitive.Root;
update the Progress function to forward the value prop to ProgressPrimitive.Root
(the component defined in this file as Progress and the Radix element
ProgressPrimitive.Root) so Radix can set aria-valuenow and manage internal
state—ensure you add value={value} to the Root while keeping the existing props
and indicator transform logic intact.

Comment on lines +129 to +152
// Process in batches of 3 to avoid overwhelming the API
const batchSize = 3;
for (let i = 0; i < jobs.length; i += batchSize) {
const batch = jobs.slice(i, i + batchSize);

const batchPromises = batch.map(async (job) => {
try {
onProgress?.(i + 1, jobs.length);
const result = await analyzeJobMatch(job, profile);
return { id: job.id || `job-${i}`, result };
} catch (error) {
return {
id: job.id || `job-${i}`,
result: {
matchScore: 0,
matchExplanation: "Unable to analyze this job listing.",
keyStrengths: [],
potentialConcerns: ["Analysis failed"],
isReach: false,
isPerfectFit: false,
} as JobMatchResult,
};
}
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix duplicate IDs and progress counts in batchAnalyzeJobs.

Line 136 uses i (batch start index) for progress, and Line 138 falls back to job-${i}. Every job in the same batch therefore shares the same ID and progress value, so results get overwritten when job.id is missing.

🐛 Proposed fix
-    const batchPromises = batch.map(async (job) => {
+    const batchPromises = batch.map(async (job, batchIndex) => {
+      const current = i + batchIndex + 1;
       try {
-        onProgress?.(i + 1, jobs.length);
+        onProgress?.(current, jobs.length);
         const result = await analyzeJobMatch(job, profile);
-        return { id: job.id || `job-${i}`, result };
+        return { id: job.id || `job-${current}`, result };
       } catch (error) {
         return {
-          id: job.id || `job-${i}`,
+          id: job.id || `job-${current}`,
           result: {
             matchScore: 0,
             matchExplanation: "Unable to analyze this job listing.",
             keyStrengths: [],
             potentialConcerns: ["Analysis failed"],
             isReach: false,
             isPerfectFit: false,
           } as JobMatchResult,
         };
       }
     });
🤖 Prompt for AI Agents
In `@job-hunting/lib/ai/client.ts` around lines 129 - 152, In batchAnalyzeJobs the
progress callback and fallback IDs use the batch start index `i`, causing
duplicate IDs and incorrect progress for items in the same batch; change the
code inside the batch.map to compute a per-job index (e.g., compute `const idx =
i + batchIndex` using the map second arg or track `i + j`) and use that `idx`
for onProgress (onProgress?.(idx + 1, jobs.length)) and for the fallback ID
(job.id || `job-${idx}`) so each job gets a unique index/ID when job.id is
missing while leaving analyzeJobMatch and the error result structure unchanged.

Comment on lines +3 to +75
import { useState, useEffect, useCallback, useMemo } from "react";

/**
* Custom hook for reactive localStorage management
* Syncs state with localStorage and handles SSR
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void, boolean] {
// Track if we're on the client - derive from window instead of useEffect
const isClient = useMemo(() => typeof window !== "undefined", []);

// Initialize state with lazy function to read from localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
if (item) {
return JSON.parse(item);
}
} catch (error) {
// Error loading from localStorage
}
return initialValue;
});

// Update localStorage when state changes
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
try {
// Allow value to be a function for updates based on previous value
const valueToStore =
value instanceof Function ? value(storedValue) : value;

setStoredValue(valueToStore);

// Only write to localStorage on client
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));

// Dispatch custom event for cross-tab sync
window.dispatchEvent(
new StorageEvent("storage", {
key,
newValue: JSON.stringify(valueToStore),
})
);
}
} catch (error) {
// Error saving to localStorage
}
},
[key, storedValue]
);

// Listen for changes from other tabs/windows
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch {
// Error parsing storage event
}
}
};

window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [key]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

React useState functional update stale closure best practice setState

💡 Result:

Best practice: avoid stale closures with useState by using the functional updater when the next value depends on the previous one

1) Use the functional updater whenever you derive next state from previous state

setCount(c => c + 1);
setTodos(todos => [...todos, newTodo]);
setForm(f => ({ ...f, name: nextName }));

React treats a function passed to the setter as an “updater function”, queues it, and applies queued updaters to the most recent pending state during the next render. This makes updates correct even under batching and rapid/overlapping updates. [1]

2) It’s required when you do multiple updates in one event (otherwise you’ll reuse the same snapshot)

setAge(a => a + 1);
setAge(a => a + 1);
setAge(a => a + 1);

If you instead wrote setAge(age + 1) three times, each call would compute from the same age value captured in that render (“state is a snapshot”), so you’d only increment once. [1]

3) It’s strongly recommended for async callbacks (where stale closures commonly happen)

If an update runs later (Promise, setTimeout, subscription, WebSocket, etc.), the callback often “remembers” old state. Prefer:

doSomethingAsync().then(() => setCount(c => c + 1));

over:

doSomethingAsync().then(() => setCount(count + 1)); // can be stale

(React’s own “queued updates” guide includes an async example where direct reads cause incorrect counters, and the fix is functional updates.) [2]

4) Don’t expect state to change immediately after calling the setter

The setter only updates state for the next render; reading the state variable right after calling it still gives the old value from the current render. [1]

Rule of thumb: if your new state is computed from existing state, default to the functional updater (setX(prev => ...)). Otherwise, passing a value directly is fine. [1]

Sources: React useState reference [1] and “Queueing a Series of State Updates” (batching + updater queue model + async pitfall) [2].

[1] https://react.dev/reference/react/useState
[2] https://react.dev/learn/queueing-a-series-of-state-updates


🏁 Script executed:

cat -n job-hunting/lib/hooks/use-local-storage.ts | head -75

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 2800


Fix stale closure in setValue and handle cross-tab item deletions in useLocalStorage.

Line 38 captures storedValue in the callback closure. Rapid functional updates compute from the same stale snapshot—calling setValue(v => v + 1) twice only increments once. Use React's functional updater pattern to always operate on the latest state.

Line 64 ignores e.newValue === null, so clearing an item in another tab won't reset local state. When a key is deleted, dispatch a storage event with newValue: null and reset to the initial value.

Proposed fix
-import { useState, useEffect, useCallback, useMemo } from "react";
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
@@
   const [storedValue, setStoredValue] = useState<T>(() => {
     if (typeof window === "undefined") {
       return initialValue;
     }
     try {
       const item = window.localStorage.getItem(key);
       if (item) {
         return JSON.parse(item);
       }
     } catch (error) {
       // Error loading from localStorage
     }
     return initialValue;
   });
+
+  const initialRef = useRef(initialValue);
@@
   const setValue = useCallback(
     (value: T | ((val: T) => T)) => {
       try {
-        // Allow value to be a function for updates based on previous value
-        const valueToStore =
-          value instanceof Function ? value(storedValue) : value;
-
-        setStoredValue(valueToStore);
-
-        // Only write to localStorage on client
-        if (typeof window !== "undefined") {
-          window.localStorage.setItem(key, JSON.stringify(valueToStore));
-
-          // Dispatch custom event for cross-tab sync
-          window.dispatchEvent(
-            new StorageEvent("storage", {
-              key,
-              newValue: JSON.stringify(valueToStore),
-            })
-          );
-        }
+        setStoredValue((prev) => {
+          const valueToStore = value instanceof Function ? value(prev) : value;
+          if (typeof window !== "undefined") {
+            window.localStorage.setItem(key, JSON.stringify(valueToStore));
+            window.dispatchEvent(
+              new StorageEvent("storage", {
+                key,
+                newValue: JSON.stringify(valueToStore),
+              })
+            );
+          }
+          return valueToStore;
+        });
       } catch (error) {
         // Error saving to localStorage
       }
     },
-    [key, storedValue]
+    [key]
   );
@@
   useEffect(() => {
     const handleStorageChange = (e: StorageEvent) => {
-      if (e.key === key && e.newValue) {
-        try {
-          setStoredValue(JSON.parse(e.newValue));
-        } catch {
-          // Error parsing storage event
-        }
-      }
+      if (e.key !== key) return;
+      if (e.newValue == null) {
+        setStoredValue(initialRef.current);
+        return;
+      }
+      try {
+        setStoredValue(JSON.parse(e.newValue));
+      } catch {
+        // Error parsing storage event
+      }
     };
🤖 Prompt for AI Agents
In `@job-hunting/lib/hooks/use-local-storage.ts` around lines 3 - 75, The bug is
that setValue closes over storedValue and misses rapid functional updates, and
storage events with newValue === null (deletions) are ignored. Fix setValue in
useLocalStorage to use the functional updater form: call setStoredValue(prev =>
{ const valueToStore = value instanceof Function ? value(prev) : value; return
valueToStore; }) and compute valueToStore inside that updater; remove
storedValue from the useCallback dependency list (rely only on key). When
persisting, if valueToStore is null or undefined call
window.localStorage.removeItem(key) and dispatch a StorageEvent with newValue:
null; otherwise setItem(key, JSON.stringify(valueToStore)) and dispatch with the
JSON string. In the storage listener (handleStorageChange) handle e.newValue ===
null by calling setStoredValue(initialValue) (and still parse e.newValue when
present). Ensure window existence checks remain for SSR.

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