Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 3 additions & 1 deletion .github/workflows/deploy-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ jobs:
with:
terraform_version: 1.12.2


- name: Set up Node
uses: actions/setup-node@v6.4.0
with:
Expand All @@ -38,6 +37,7 @@ jobs:
key: yarn-modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
restore-keys: |
yarn-modules-${{ runner.arch }}-${{ runner.os }}-
save: ${{ github.ref == 'refs/heads/main' }}

- name: Run unit testing
run: make test_unit
Expand Down Expand Up @@ -66,6 +66,7 @@ jobs:
key: yarn-modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
restore-keys: |
yarn-modules-${{ runner.arch }}-${{ runner.os }}-
save: ${{ github.ref == 'refs/heads/main' }}

- name: Run build
run: make build
Expand Down Expand Up @@ -120,6 +121,7 @@ jobs:
key: yarn-modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
restore-keys: |
yarn-modules-${{ runner.arch }}-${{ runner.os }}-
save: ${{ github.ref == 'refs/heads/main' }}

- name: Download Build files
uses: actions/download-artifact@v8
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "infra-core",
"version": "4.9.5",
"version": "4.9.6",
"private": true,
"type": "module",
"workspaces": [
Expand Down Expand Up @@ -93,4 +93,4 @@
"pdfjs-dist": "^4.8.69",
"form-data": "^4.0.4"
}
}
}
177 changes: 116 additions & 61 deletions src/ui/components/NameOptionalCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import {
useEffect,
useState,
useRef,
useCallback,
ReactNode,
} from "react";
import { Avatar, Group, Text, Skeleton, Badge } from "@mantine/core";
import { useApi } from "@ui/util/api";
import { BatchResolveUserInfoResponse } from "@common/types/user";
import { useAuth } from "../AuthContext";

// Types
interface UserData {
email: string;
name?: string;
Expand All @@ -25,34 +25,34 @@ const AVATAR_SIZES = {
xl: 84,
} as const;

// Basic email validation regex
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

interface UserResolverContextType {
resolveUser: (email: string) => UserData | undefined;
requestUser: (email: string) => void;
registerCard: (email: string, element: Element) => void;
unregisterCard: (element: Element) => void;
isResolving: (email: string) => boolean;
resolutionDisabled: boolean;
cacheVersion: number;
}

// Context
const UserResolverContext = createContext<UserResolverContextType | null>(null);

// Provider Props
interface UserResolverProviderProps {
children: ReactNode;
batchDelay?: number;
resolutionDisabled?: boolean;
/** Number of additional cards to prefetch beyond the one entering the viewport */
prefetchAhead?: number;
}

// Sentinel value to indicate we've checked and there's no name
const NO_NAME_FOUND = Symbol("NO_NAME_FOUND");

export function UserResolverProvider({
children,
batchDelay = 50,
resolutionDisabled = false,
prefetchAhead = 10,
}: UserResolverProviderProps) {
const api = useApi("core");
const [userCache, setUserCache] = useState<
Expand All @@ -62,14 +62,25 @@ export function UserResolverProvider({
const pendingRequests = useRef<Set<string>>(new Set());
const batchTimeout = useRef<NodeJS.Timeout | null>(null);

// Refs so observer callback always sees latest values without recreating the observer
const userCacheRef = useRef(userCache);
userCacheRef.current = userCache;
const resolutionDisabledRef = useRef(resolutionDisabled);
resolutionDisabledRef.current = resolutionDisabled;
const prefetchAheadRef = useRef(prefetchAhead);
prefetchAheadRef.current = prefetchAhead;

// element -> email, and ordered email list matching visual registration order
const elementToEmail = useRef<Map<Element, string>>(new Map());
const orderedEmails = useRef<string[]>([]);
const intersectionObserver = useRef<IntersectionObserver | null>(null);
const requestUserRef = useRef<(email: string) => void>(() => {});

const fetchUsers = async (emailsToFetch: string[]) => {
const response = await api.post<BatchResolveUserInfoResponse>(
"/api/v1/users/batchResolveInfo",
{
emails: emailsToFetch,
},
{ emails: emailsToFetch },
);

const emailToName: Record<string, string | typeof NO_NAME_FOUND> = {};
for (const email of emailsToFetch) {
const userData = response.data[email];
Expand All @@ -82,18 +93,15 @@ export function UserResolverProvider({
emailToName[email] = NO_NAME_FOUND;
}
}

return emailToName;
};

const executeBatch = async () => {
if (pendingRequests.current.size === 0) {
return;
}

const emailsToFetch = Array.from(pendingRequests.current);
pendingRequests.current.clear();

try {
const results = await fetchUsers(emailsToFetch);
setUserCache((prev) => {
Expand All @@ -114,58 +122,108 @@ export function UserResolverProvider({
};

const requestUser = (email: string) => {
// If resolution is disabled, mark as NO_NAME_FOUND immediately
if (resolutionDisabled) {
if (resolutionDisabledRef.current) {
setUserCache((prev) => {
setCacheVersion((v) => v + 1);
return { ...prev, [email]: NO_NAME_FOUND };
});
return;
}

// Skip if already cached (including NO_NAME_FOUND sentinel)
if (email in userCache) {
if (email in userCacheRef.current) {
return;
}

// Validate email format - if invalid, mark as NO_NAME_FOUND immediately
if (!EMAIL_REGEX.test(email)) {
setUserCache((prev) => {
setCacheVersion((v) => v + 1);
return { ...prev, [email]: NO_NAME_FOUND };
});
return;
}

pendingRequests.current.add(email);

// Clear existing timeout and set new one
if (batchTimeout.current) {
clearTimeout(batchTimeout.current);
}

batchTimeout.current = setTimeout(() => {
executeBatch();
}, batchDelay);
batchTimeout.current = setTimeout(executeBatch, batchDelay);
};
requestUserRef.current = requestUser;

const isResolving = (email: string): boolean => {
return !(email in userCache);
};

const resolveUser = (email: string): UserData | undefined => {
const cached = userCache[email];
if (!cached || cached === NO_NAME_FOUND) {
return undefined;
// Lazily create the observer on first registerCard call so it exists before any card's useEffect runs
const getOrCreateObserver = useCallback(() => {
if (intersectionObserver.current) {
return intersectionObserver.current;
}
return { email, name: cached };
};
intersectionObserver.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const email = elementToEmail.current.get(entry.target);
if (email) {
const idx = orderedEmails.current.indexOf(email);
const batch =
idx === -1
? [email]
: orderedEmails.current.slice(
idx,
idx + prefetchAheadRef.current + 1,
);
batch.forEach((e) => requestUserRef.current(e));
intersectionObserver.current?.unobserve(entry.target);
}
}
}
},
{ rootMargin: "0px" },
);
return intersectionObserver.current;
}, []);

useEffect(() => {
return () => {
intersectionObserver.current?.disconnect();
};
}, []);

const registerCard = useCallback(
(email: string, element: Element) => {
if (email in userCacheRef.current) {
return;
}
elementToEmail.current.set(element, email);
if (!orderedEmails.current.includes(email)) {
orderedEmails.current.push(email);
}
getOrCreateObserver().observe(element);
},
[getOrCreateObserver],
);

const unregisterCard = useCallback((element: Element) => {
elementToEmail.current.delete(element);
intersectionObserver.current?.unobserve(element);
}, []);

const resolveUser = useCallback(
(email: string): UserData | undefined => {
const cached = userCache[email];
if (!cached || cached === NO_NAME_FOUND) {
return undefined;
}
return { email, name: cached };
},
[userCache],
);

const isResolving = useCallback(
(email: string): boolean => !(email in userCache),
[userCache],
);

return (
<UserResolverContext.Provider
value={{
resolveUser,
requestUser,
registerCard,
unregisterCard,
isResolving,
resolutionDisabled,
cacheVersion,
Expand All @@ -176,7 +234,6 @@ export function UserResolverProvider({
);
}

// Hook
function useUserResolver() {
const context = useContext(UserResolverContext);
if (!context) {
Expand All @@ -185,7 +242,6 @@ function useUserResolver() {
return context;
}

// Component Props
interface NameOptionalUserCardProps {
email: string;
name?: string;
Expand All @@ -194,7 +250,6 @@ interface NameOptionalUserCardProps {
resolutionDisabled?: boolean;
}

// Component
export function NameOptionalUserCard({
name: providedName,
email,
Expand All @@ -204,7 +259,8 @@ export function NameOptionalUserCard({
}: NameOptionalUserCardProps) {
const {
resolveUser,
requestUser,
registerCard,
unregisterCard,
isResolving,
resolutionDisabled: contextResolutionDisabled,
cacheVersion,
Expand All @@ -221,28 +277,27 @@ export function NameOptionalUserCard({

const isValidEmail = EMAIL_REGEX.test(email);

// Only request user data when the card is near the viewport (200px pre-fetch margin)
// Register with the provider's shared observer; it handles in-view + next N prefetch
useEffect(() => {
if (resolutionDisabled || providedName || !isValidEmail) {
if (
resolutionDisabled ||
providedName ||
!isValidEmail ||
!containerRef.current
) {
return;
}

const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
requestUser(email);
observer.disconnect();
}
},
{ rootMargin: "200px" },
);

if (containerRef.current) {
observer.observe(containerRef.current);
}

return () => observer.disconnect();
}, [email, providedName, isValidEmail, resolutionDisabled, requestUser]);
const element = containerRef.current;
registerCard(email, element);
return () => unregisterCard(element);
}, [
email,
providedName,
isValidEmail,
resolutionDisabled,
registerCard,
unregisterCard,
]);

// Read from cache whenever it updates (independent of visibility)
useEffect(() => {
Expand Down
Loading