wacrm/src/hooks/use-realtime.ts
Arnas Donauskas 047b981821
ci: clear lint error backlog, drop continue-on-error on lint step
Fixes the 14 pre-existing lint errors surfaced by React 19's new
hook rules so CI can enforce lint on every PR instead of tolerating
failures. The errors split roughly into three camps:

1) react-hooks/refs — "Cannot access refs during render"
   4 occurrences of the "latest ref" pattern that assigned
   `ref.current = x` during render. Moved each assignment into a
   `useEffect(() => { ref.current = x })` — the canonical React-19
   pattern. Consumers only read `.current` inside async callbacks
   that run after commit, so the very slight delay to the update is
   fine.
   Touched: src/hooks/use-realtime.ts (onMessageRef,
   onConversationRef), src/components/inbox/message-thread.tsx
   (onMessagesLoadedRef), src/components/inbox/conversation-list.tsx
   (onConversationsLoadedRef).

2) react-hooks/set-state-in-effect — genuine issues
   - contacts/page.tsx: the `setPage(0)` on search change moved from
     a useEffect into the `onChange` handler of the search input,
     which is the cleaner pattern anyway.
   - settings/page.tsx: the local `tab` state + sync effect were
     removed entirely; the URL query param is now the single source
     of truth, and the `onChange` handler just calls
     `router.replace(...)`. Simpler and fixes the lint error.

3) react-hooks/set-state-in-effect — false positives for
   load-on-mount and prop-driven sync
   Suppressed per-call (or per-block for effects with many setters)
   with explanatory comments. These effects use setters inside
   async Supabase-query callbacks, not synchronously in the effect
   body, so the cascade the rule warns about doesn't apply. Files:
   contacts/page.tsx, pipelines/page.tsx, pipelines/deal-form.tsx,
   pipelines/pipeline-settings.tsx,
   components/inbox/contact-sidebar.tsx.

4) react-hooks/preserve-manual-memoization — one useCallback
   Rule complained that the inferred dep was `contact` but the
   source dep list was `[contact?.phone]`. Broadened the manual dep
   to `[contact]` to match — callback behaviour is unchanged (it
   still only reads `contact?.phone`), and the React Compiler can
   now preserve the memoization.

And:
  - .github/workflows/ci.yml: dropped `continue-on-error: true` on
    the lint step. Lint now blocks merge on any new error.

Verified: `npm run lint` → 0 errors (24 warnings remain; all
pre-existing unused-import / missing-dep / <img> warnings that don't
fail CI). `npm run typecheck` clean. Landing, /docs, /login, and
/settings redirects all still respond correctly from the dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:28:35 +03:00

95 lines
2.8 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { Message, Conversation } from "@/types";
import type { RealtimeChannel } from "@supabase/supabase-js";
interface RealtimeEvent<T> {
eventType: "INSERT" | "UPDATE" | "DELETE";
new: T;
old: Partial<T>;
}
interface UseRealtimeOptions {
channelName: string;
onMessageEvent?: (event: RealtimeEvent<Message>) => void;
onConversationEvent?: (event: RealtimeEvent<Conversation>) => void;
enabled?: boolean;
}
export function useRealtime({
channelName,
onMessageEvent,
onConversationEvent,
enabled = true,
}: UseRealtimeOptions) {
const channelRef = useRef<RealtimeChannel | null>(null);
const [isConnected, setIsConnected] = useState(false);
// Store latest callbacks in refs to avoid re-subscribing when the
// parent re-renders with fresh closures. Assigned inside an effect
// so the mutation doesn't happen during render (React 19's refs
// rule) — subscribers only read `.current` inside async Realtime
// callbacks, which always run after the render that updates it.
const onMessageRef = useRef(onMessageEvent);
const onConversationRef = useRef(onConversationEvent);
useEffect(() => {
onMessageRef.current = onMessageEvent;
onConversationRef.current = onConversationEvent;
});
useEffect(() => {
if (!enabled) return;
const supabase = createClient();
const channel = supabase
.channel(channelName)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
(payload) => {
onMessageRef.current?.({
eventType: payload.eventType as RealtimeEvent<Message>["eventType"],
new: payload.new as Message,
old: payload.old as Partial<Message>,
});
}
)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "conversations" },
(payload) => {
onConversationRef.current?.({
eventType: payload.eventType as RealtimeEvent<Conversation>["eventType"],
new: payload.new as Conversation,
old: payload.old as Partial<Conversation>,
});
}
)
.subscribe((status) => {
setIsConnected(status === "SUBSCRIBED");
});
channelRef.current = channel;
return () => {
supabase.removeChannel(channel);
channelRef.current = null;
setIsConnected(false);
};
}, [channelName, enabled]);
const unsubscribe = useCallback(() => {
if (channelRef.current) {
const supabase = createClient();
supabase.removeChannel(channelRef.current);
channelRef.current = null;
setIsConnected(false);
}
}, []);
return { isConnected, unsubscribe };
}