Merged in feature/IO-3499-React-19 (pull request #2858)

feature/IO-3499-React-19 checkpoint
This commit is contained in:
Dave Richer
2026-01-21 16:55:07 +00:00
2 changed files with 313 additions and 76 deletions

View File

@@ -1,45 +1,201 @@
import * as Sentry from "@sentry/react";
import { onError } from "@apollo/client/link/error";
import { ErrorLink } from "@apollo/client/link/error";
import { CombinedGraphQLErrors, CombinedProtocolErrors } from "@apollo/client/errors";
import { print } from "graphql";
import { getMainDefinition } from "@apollo/client/utilities";
import { auth } from "../firebase/firebase.utils";
import { from, throwError } from "rxjs";
import { catchError, mergeMap } from "rxjs/operators";
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
const SENSITIVE_KEYS = new Set([
"password",
"token",
"authorization",
"accessToken",
"refreshToken",
"idToken",
"session"
]);
let refreshInFlight = null;
const refreshTokenOnce = () => {
if (!refreshInFlight) {
refreshInFlight = auth.currentUser ? auth.currentUser.getIdToken(true) : Promise.resolve(null);
refreshInFlight.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
};
const redactVariables = (variables) => {
if (!variables || typeof variables !== "object") return variables;
const out = { ...variables };
for (const key of Object.keys(out)) {
if (SENSITIVE_KEYS.has(key)) out[key] = "[REDACTED]";
}
return out;
};
const safePrintQuery = (query) => {
try {
return query ? print(query) : undefined;
} catch {
return undefined;
}
};
const isSubscriptionOperation = (operation) => {
try {
const def = getMainDefinition(operation.query);
return def?.kind === "OperationDefinition" && def?.operation === "subscription";
} catch {
return false;
}
};
const extractGraphQLErrors = ({ error, result }) => {
// Primary: Apollo Client 4+ GraphQL errors
if (CombinedGraphQLErrors.is(error)) return error.errors;
// Fallback: raw GraphQL response errors (what apollo-link-logger shows)
if (Array.isArray(result?.errors) && result.errors.length) return result.errors;
// Fallback: some “network-ish” errors still carry result.errors
if (Array.isArray(error?.result?.errors) && error.result.errors.length) return error.result.errors;
return null;
};
const hasUnauthCode = (errs) => Array.isArray(errs) && errs.some((e) => e?.extensions?.code === "UNAUTHENTICATED");
const errorLink = new ErrorLink(({ error, result, operation, forward }) => {
const operationName = operation?.operationName || "anonymous";
const ctx = operation?.getContext?.() || {};
const callerStack = ctx?.callerStack;
if (graphQLErrors?.length) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
const variables = redactVariables(operation?.variables);
const queryText = safePrintQuery(operation?.query);
const gqlErrors = extractGraphQLErrors({ error, result });
const protocolErrors = CombinedProtocolErrors.is(error) ? error.errors : null;
// ---- Single auth-refresh retry (HTTP only, once) ----
// Only retry if:
// - we have GraphQL errors with UNAUTHENTICATED
// - not a subscription
// - not already retried
// - forward exists (so we can re-run)
if (forward && !isSubscriptionOperation(operation) && !ctx.__didAuthRetry && hasUnauthCode(gqlErrors)) {
operation.setContext({ ...ctx, __didAuthRetry: true });
return from(refreshTokenOnce()).pipe(
mergeMap(() => forward(operation)),
catchError(() => throwError(() => error))
);
}
// ---- GraphQL errors ----
if (gqlErrors?.length) {
// Make it show up in the “Errors” filter (red)
console.error(`[GraphQL error] ${operationName}`, {
message: gqlErrors[0]?.message
});
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql", {
operationName
// variables can contain PII; include only if youre comfortable with it
// variables: operation.variables
if (import.meta.env.DEV) {
console.groupCollapsed?.(`[GraphQL error details] ${operationName}`);
console.log({
operationName,
variables,
query: queryText,
callerStack
});
graphQLErrors.forEach((err) => {
scope.setContext("graphql.error", {
message: err.message,
path: err.path,
locations: err.locations
gqlErrors.forEach((e, i) => {
console.log(`Error #${i + 1}:`, {
message: e.message,
path: e.path,
locations: e.locations,
extensions: e.extensions
});
Sentry.captureMessage(`[GraphQL] ${err.message}`);
});
});
}
if (networkError) {
console.log(`[Network error]: ${JSON.stringify(networkError)}`);
console.groupEnd?.();
}
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
Sentry.captureException(networkError);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
scope.setContext(
"graphql.errors",
gqlErrors.map((e) => ({
message: e.message,
path: e.path,
locations: e.locations,
extensions: e.extensions
}))
);
Sentry.captureMessage(`[GraphQL] ${operationName}`);
});
// IMPORTANT: do NOT return forward(operation) unless intentionally retrying
return;
}
return forward(operation);
// ---- Protocol errors (rare, but good to log) ----
if (protocolErrors?.length) {
console.error(`[Protocol error] ${operationName}`, {
message: protocolErrors[0]?.message
});
if (import.meta.env.DEV) {
console.groupCollapsed?.(`[Protocol error details] ${operationName}`);
console.log({ operationName, variables, query: queryText, callerStack });
protocolErrors.forEach((e, i) => console.log(`Protocol Error #${i + 1}:`, e));
console.groupEnd?.();
}
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
scope.setContext("graphql.protocolErrors", protocolErrors);
Sentry.captureMessage(`[GraphQL Protocol] ${operationName}`);
});
return;
}
// ---- Network / other errors ----
if (error) {
console.error(`[Network error] ${operationName}`, error);
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
Sentry.captureException(error);
});
}
});
export default errorLink;

View File

@@ -1,4 +1,3 @@
// file: client/src/utils/GraphQLClient.js
import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { HttpLink } from "@apollo/client/link/http";
@@ -13,45 +12,73 @@ import { map } from "rxjs/operators";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
/**
* HTTP transport
*/
const httpLink = new HttpLink({
uri: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT
});
const wsLink = new GraphQLWsLink(
createClient({
url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS,
lazy: true,
connectionParams: async () => {
const token = auth.currentUser && (await auth.currentUser.getIdToken());
return {
headers: {
authorization: token ? `Bearer ${token}` : ""
}
};
}
})
);
/**
* WS transport (graphql-ws)
* - Auth header is established at connect-time via connectionParams.
* - Reconnect behavior is handled here (NOT via RetryLink).
*/
const wsClient = createClient({
url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS,
lazy: true,
lazyCloseTimeout: 5000,
// Split based on operation type (subscription -> ws, everything else -> http)
const terminatingLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
retryAttempts: Infinity,
shouldRetry: (eventOrError) => {
// Don't retry a clean close (1000); retry everything else
const code = eventOrError?.code;
if (typeof code === "number") return code !== 1000;
return true;
},
wsLink,
httpLink
);
// function TrackExecutionTime(operationName, timeMs) {
// keep your existing implementation/commented code here
// This signature now matches the call site.
// }
connectionParams: async () => {
const token = await auth.currentUser?.getIdToken();
return token
? {
headers: {
authorization: `Bearer ${token}`
}
}
: {};
}
});
// Apollo Client 4 uses RxJS under the hood; use pipe(map(...)) instead of .map(...)
const wsLink = new GraphQLWsLink(wsClient);
/**
* DEV-only: attach a caller stack to every operation so errors can point back
* to the initiating module/component.
*/
const callerStackLink = import.meta.env.DEV
? new ApolloLink((operation, forward) => {
const stack = new Error().stack;
operation.setContext((prev) => ({
...prev,
callerStack: stack
}));
return forward(operation);
})
: ApolloLink.empty();
/**
* Round-trip timing (HTTP only)
* NOTE: Subscriptions are long-lived streams; timing/retry should not wrap them.
*/
const roundTripLink = new ApolloLink((operation, forward) => {
operation.setContext({ start: Date.now() });
return forward(operation).pipe(
const obs = forward?.(operation);
if (!obs) return null;
return obs.pipe(
map((result) => {
// const start = operation.getContext().start;
// const timeMs = Date.now() - start;
@@ -61,9 +88,12 @@ const roundTripLink = new ApolloLink((operation, forward) => {
);
});
/**
* Auth for HTTP requests (per-request token)
*/
const authLink = setContext(async (_, { headers }) => {
try {
const token = auth.currentUser && (await auth.currentUser.getIdToken());
const token = await auth.currentUser?.getIdToken();
if (!token) return { headers };
return {
@@ -78,28 +108,65 @@ const authLink = setContext(async (_, { headers }) => {
}
});
/**
* Retry (HTTP only) — transient/network failures only.
* Avoid retrying deterministic GraphQL errors (validation, missing variables, etc).
*/
const retryLink = new RetryLink({
delay: {
initial: 500,
// Keeping your intent (a cap), but make it milliseconds (5ms is almost certainly not intended).
max: 5000,
jitter: true
},
attempts: {
max: 5,
retryIf: (error) => !!error
retryIf: (error) => {
// Apollo's network errors often expose statusCode/response.status
const status = error?.statusCode ?? error?.response?.status;
if (typeof status === "number") {
// Common transient HTTP statuses
return [408, 429, 500, 502, 503, 504].includes(status);
}
// Fallback heuristic for fetch/socket transient failures
const msg = `${error?.message || ""}`;
return /network|fetch|timeout|ECONNRESET|ENOTFOUND|EAI_AGAIN/i.test(msg);
}
}
});
const links = [];
/**
* Branch chains
* - HTTP branch includes timing + retry + auth.
* - WS branch is intentionally lean; graphql-ws handles reconnect.
*
* NOTE:
* errorLink is applied OUTSIDE the split so it sees both HTTP + WS operations.
* Placing errorLink upstream of retryLink means it generally logs only after retries are exhausted.
*/
const httpChain = ApolloLink.from([roundTripLink, retryLink, authLink, httpLink]);
const wsChain = ApolloLink.from([wsLink]);
if (import.meta.env.DEV) {
links.push(apolloLogger);
}
const isSubscriptionOperation = ({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
};
// Order: timing -> retry -> error -> auth -> network split
links.push(roundTripLink, retryLink, errorLink, authLink, terminatingLink);
const terminatingLink = split(isSubscriptionOperation, wsChain, httpChain);
/**
* Final link chain
* - errorLink wraps both branches (HTTP + WS)
* - callerStackLink adds call-site stack (DEV only)
* - apollo-link-logger only in DEV
*/
const link = ApolloLink.from(
import.meta.env.DEV ? [apolloLogger, callerStackLink, errorLink, terminatingLink] : [errorLink, terminatingLink]
);
/**
* Cache policies
*/
const cache = new InMemoryCache({
typePolicies: {
Query: {
@@ -114,6 +181,7 @@ const cache = new InMemoryCache({
merged[offset + i] = incoming[i];
}
// Deduplicate by id (important when you also upsert via sockets)
const seen = new Set();
return merged.filter((ref) => {
const id = readField("id", ref);
@@ -125,20 +193,30 @@ const cache = new InMemoryCache({
},
notifications: {
merge(existing = [], incoming = [], { readField }) {
const merged = new Map();
// Keep "respect the current query filter" behavior (i.e., don't union with existing),
// but dedupe within the incoming page/list so UI doesn't flicker with duplicates.
// eslint-disable-next-line no-unused-vars
merge(_existing = [], incoming = [], { readField }) {
const seen = new Set();
const out = [];
existing.forEach((item) => {
const ref = readField("__ref", item);
if (ref) merged.set(ref, item);
});
for (const item of incoming) {
const typename = readField("__typename", item);
const id = readField("id", item);
incoming.forEach((item) => {
const ref = readField("__ref", item);
if (ref) merged.set(ref, item);
});
const key = typename && id ? `${typename}:${id}` : null;
return incoming;
if (!key) {
out.push(item);
continue;
}
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
}
}
@@ -146,8 +224,11 @@ const cache = new InMemoryCache({
}
});
/**
* Client
*/
const client = new ApolloClient({
link: ApolloLink.from(links),
link,
cache,
devtools: {
name: "Imex Client",