Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 122 additions & 16 deletions apps/dashboard/src/lib/use-github-signal-stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { type MutableRefObject, useEffect, useMemo, useRef } from "react";
Copy link
Copy Markdown
Contributor

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

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Declared React versions in package manifests:"
python - <<'PY'
import json
from pathlib import Path

for path in Path(".").rglob("package.json"):
    try:
        data = json.loads(path.read_text())
    except Exception:
        continue

    deps = {}
    for bucket in ("dependencies", "devDependencies", "peerDependencies"):
        deps.update(data.get(bucket, {}))

    react = deps.get("react")
    types = deps.get("@types/react")
    if react or types:
        print(f"{path}: react={react or 'n/a'}, `@types/react`={types or 'n/a'}")
PY

echo
echo "Current MutableRefObject usages:"
rg -n --type=ts --type=tsx '\bMutableRefObject\b'

Repository: stylessh/diffkit

Length of output: 376


🏁 Script executed:

cat -n apps/dashboard/src/lib/use-github-signal-stream.ts | head -200

Repository: stylessh/diffkit

Length of output: 6750


🏁 Script executed:

rg -n 'MutableRefObject' --type ts apps/dashboard/src/lib/

Repository: stylessh/diffkit

Length of output: 423


🏁 Script executed:

cat -n apps/dashboard/src/lib/use-github-signal-stream.ts | sed -n '170,200p; 330,360p'

Repository: stylessh/diffkit

Length of output: 1909


🌐 Web query:

React 19 MutableRefObject RefObject ref type changes

💡 Result:

In React 19, significant changes were made to ref types in TypeScript via @types/react updates to align with runtime improvements, particularly making refs mutable by default and simplifying useRef. Key changes: - useRef now requires an initial argument (e.g., useRef(null) or useRef(undefined)). The zero-argument form is removed. - RefObject now has mutable current: T (not readonly T | null). Previously, current was readonly T | null. - MutableRefObject is deprecated in favor of the unified RefObject, which useRef always returns. - For ref as a prop (new React 19 feature, reducing need for forwardRef), use React.Ref for the prop type (union of RefObject | ((instance: T | null) => void) | null | undefined). These changes improve type safety and reflect common usage patterns. Codemods like types-react-codemod refobject-defaults and useref-required-initial help migration. Examples: const ref = useRef(null); // RefObject, current: HTMLDivElement | null (but mutable) ref.current = someElement; // Now allowed, previously readonly error // For component props in React 19: interface Props { ref?: React.Ref; } function MyComponent({ ref, ...props }: Props) { return <div ref={ref} {...props} />; } Official React docs do not detail TypeScript types (runtime-focused), but upgrade guide mentions ref cleanups and useRef requiring argument. Changes implemented in @types/react for React 19 compatibility.

Citations:


Replace deprecated MutableRefObject with RefObject for React 19 compatibility.

MutableRefObject is deprecated in React 19 as of @types/react@^19.2.0. Refactor line 2's import and the type annotations on lines 187 and 349 to use RefObject<Map<string, number>> instead, which is the unified ref type in React 19 with mutable .current.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/lib/use-github-signal-stream.ts` at line 2, Replace the
deprecated MutableRefObject import with RefObject in the module import statement
and update any type annotations that currently use MutableRefObject<Map<string,
number>> to RefObject<Map<string, number>> (notably the ref declarations used
inside the useGithubSignalStream hook); ensure the hook and any functions that
mutate the ref continue to access and assign via .current so the unified
RefObject type works correctly with React 19.

import { debug } from "./debug";
import { getRevalidationSignalTimestamps } from "./github.functions";
import { type GitHubQueryScope, githubQueryKeys } from "./github.query";
Expand Down Expand Up @@ -133,9 +133,54 @@ export function invalidateTargets(
return invalidatedCount;
}

function signalStreamCompositeKey(
queryKey: QueryKey,
signalKey: string,
): string {
return `${JSON.stringify(queryKey)}\0${signalKey}`;
}

/** Sync server signal timestamps with local query ages; mutates lastSeenTimestamps (per queryKey+signalKey). */
function collectKeysToInvalidateAfterServerSync(
queryClient: ReturnType<typeof useQueryClient>,
targets: readonly GitHubSignalStreamTarget[],
signals: Array<{ signalKey: string; updatedAt: number }>,
lastSeenTimestamps: Map<string, number>,
): string[] {
const updatedKeys = new Set<string>();

for (const signal of signals) {
for (const target of targets) {
if (!target.signalKeys.includes(signal.signalKey)) {
continue;
}

const compositeKey = signalStreamCompositeKey(
target.queryKey,
signal.signalKey,
);
const lastSeen = lastSeenTimestamps.get(compositeKey);
const qs = queryClient.getQueryState(target.queryKey);

if (lastSeen === undefined) {
if (qs && qs.dataUpdatedAt > 0 && signal.updatedAt > qs.dataUpdatedAt) {
updatedKeys.add(signal.signalKey);
}
lastSeenTimestamps.set(compositeKey, signal.updatedAt);
} else if (signal.updatedAt > lastSeen) {
lastSeenTimestamps.set(compositeKey, signal.updatedAt);
updatedKeys.add(signal.signalKey);
}
}
}

return Array.from(updatedKeys);
}

function useGitHubSignalStreamWebSocket(
targets: readonly GitHubSignalStreamTarget[],
signalKeysKey: string,
lastSeenTimestampsRef: MutableRefObject<Map<string, number>>,
) {
const queryClient = useQueryClient();
const targetsRef = useRef(targets);
Expand All @@ -151,6 +196,45 @@ function useGitHubSignalStreamWebSocket(
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;

async function syncSignalsFromServer(source: string) {
try {
const signals = await getRevalidationSignalTimestamps({
data: { signalKeys: keys },
});
if (disposed) return;

const updatedKeys = collectKeysToInvalidateAfterServerSync(
queryClient,
targetsRef.current,
signals,
lastSeenTimestampsRef.current,
);

if (updatedKeys.length === 0) {
return;
}

debug(source, "detected missed or stale cache vs signals", {
updatedKeys,
});

const invalidatedCount = invalidateTargets(
queryClient,
targetsRef.current,
new Set(updatedKeys),
source,
);

debug(source, "sync processed", {
updatedKeys,
invalidatedCount,
totalTargets: targetsRef.current.length,
});
} catch (error) {
debug(source, "sync failed", { error });
}
}

function sendSubscription(socket: WebSocket) {
if (socket.readyState === WebSocket.OPEN) {
debug("github-signal-stream", "subscribing to signal keys", {
Expand Down Expand Up @@ -212,6 +296,7 @@ function useGitHubSignalStreamWebSocket(
ws.addEventListener("open", () => {
debug("github-signal-stream", "connected");
if (ws) sendSubscription(ws);
void syncSignalsFromServer("github-signal-ws-catchup");
});

ws.addEventListener("message", handleMessage);
Expand Down Expand Up @@ -251,12 +336,13 @@ function useGitHubSignalStreamWebSocket(
ws.close();
}
};
}, [signalKeysKey, queryClient]);
}, [signalKeysKey, queryClient, lastSeenTimestampsRef]);
}

function useGitHubSignalPoll(
targets: readonly GitHubSignalStreamTarget[],
signalKeysKey: string,
lastSeenTimestampsRef: MutableRefObject<Map<string, number>>,
) {
const queryClient = useQueryClient();
const targetsRef = useRef(targets);
Expand All @@ -270,7 +356,6 @@ function useGitHubSignalPoll(
const keys = signalKeysKey.split(",");
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const lastSeenTimestamps = new Map<string, number>();

async function pollSignals() {
if (disposed) return;
Expand All @@ -282,16 +367,12 @@ function useGitHubSignalPoll(

if (disposed) return;

const updatedKeys: string[] = [];
for (const signal of signals) {
const lastSeen = lastSeenTimestamps.get(signal.signalKey);
if (lastSeen === undefined) {
lastSeenTimestamps.set(signal.signalKey, signal.updatedAt);
} else if (signal.updatedAt > lastSeen) {
lastSeenTimestamps.set(signal.signalKey, signal.updatedAt);
updatedKeys.push(signal.signalKey);
}
}
const updatedKeys = collectKeysToInvalidateAfterServerSync(
queryClient,
targetsRef.current,
signals,
lastSeenTimestampsRef.current,
);

if (updatedKeys.length > 0) {
debug("github-signal-poll", "detected missed signals", {
Expand Down Expand Up @@ -335,7 +416,7 @@ function useGitHubSignalPoll(
clearTimeout(pollTimer);
}
};
}, [signalKeysKey, queryClient]);
}, [signalKeysKey, queryClient, lastSeenTimestampsRef]);
}

export function useGitHubSignalStream(
Expand All @@ -356,6 +437,31 @@ export function useGitHubSignalStream(
// not when the array reference changes.
const signalKeysKey = allSignalKeys.join(",");

useGitHubSignalStreamWebSocket(mergedTargets, signalKeysKey);
useGitHubSignalPoll(mergedTargets, signalKeysKey);
const mergedTargetsIdentity = useMemo(
() =>
mergedTargets
.map(
(t) =>
`${JSON.stringify(t.queryKey)}\0${[...t.signalKeys].sort().join(",")}`,
)
.sort()
.join("|"),
[mergedTargets],
);

const lastSeenTimestampsRef = useRef(new Map<string, number>());

useEffect(() => {
// Reference deps so the reset runs when subscription identity changes (Biome exhaustive-deps).
void signalKeysKey;
void mergedTargetsIdentity;
lastSeenTimestampsRef.current = new Map();
}, [signalKeysKey, mergedTargetsIdentity]);

useGitHubSignalStreamWebSocket(
mergedTargets,
signalKeysKey,
lastSeenTimestampsRef,
);
useGitHubSignalPoll(mergedTargets, signalKeysKey, lastSeenTimestampsRef);
}
Loading