Promote useMutation to the @probo/relay package

Extract the awaitable useMutation into @probo/relay as a
createUseMutation factory that delegates feedback to an injected
MutationNotifier, keeping the package free of UI and i18n
dependencies. compliance-portal binds it to its Base UI toast +
i18next + formatError stack and imports it by explicit path
(#/lib/relay/useMutation), dropping the lone intra-app barrel; a
compliance-portal-scoped no-restricted-imports rule forbids
react-relay's useMutation.

Bring packages/relay and packages/routes into the shared ESLint
scope and fix the violations that surfaced, and deprecate the
legacy withQueryRef / loaderFromQueryLoader helpers. Document the
shared-hook pattern and the "index.ts for package entrypoints only"
rule in the relay, hooks, and app-arborescence guides.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-24 10:28:57 +02:00
parent e93faf4caa
commit ff966b462e
14 changed files with 684 additions and 122 deletions

View File

@@ -48,7 +48,7 @@ export class AssumptionRequiredError extends Error {
constructor(message?: string) {
super(message ?? "ASSUMPTION_REQUIRED");
this.name = "AssumptionRequiredError";
Object.setPrototypeOf(this, AssumptionRequiredError.prototype)
Object.setPrototypeOf(this, AssumptionRequiredError.prototype);
}
}

View File

@@ -12,117 +12,120 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { type FetchFunction } from "relay-runtime";
import {
InternalServerError,
UnAuthenticatedError,
ForbiddenError,
AssumptionRequiredError,
NDASignatureRequiredError,
FullNameRequiredError,
} from "./errors";
import { GraphQLError } from "graphql";
import { type FetchFunction, type GraphQLResponse } from "relay-runtime";
import {
AssumptionRequiredError,
ForbiddenError,
FullNameRequiredError,
InternalServerError,
NDASignatureRequiredError,
UnAuthenticatedError,
} from "./errors";
const hasUnauthenticatedError = (error: GraphQLError) =>
error.extensions?.code === "UNAUTHENTICATED";
error.extensions?.code === "UNAUTHENTICATED";
const hasFullNameRequiredError = (error: GraphQLError) =>
error.extensions?.code === "FULL_NAME_REQUIRED";
error.extensions?.code === "FULL_NAME_REQUIRED";
const hasAssumptionRequiredError = (error: GraphQLError) =>
error.extensions?.code === "ASSUMPTION_REQUIRED";
error.extensions?.code === "ASSUMPTION_REQUIRED";
const hasNDASignatureRequiredError = (error: GraphQLError) =>
error.extensions?.code === "NDA_SIGNATURE_REQUIRED";
error.extensions?.code === "NDA_SIGNATURE_REQUIRED";
const hasForbiddenError = (error: GraphQLError) =>
error.extensions?.code === "FORBIDDEN";
error.extensions?.code === "FORBIDDEN";
export const makeFetchQuery = (endpoint: string): FetchFunction => {
return async (request, variables, _, uploadables) => {
const requestInit: RequestInit = {
method: "POST",
credentials: "include",
headers: {},
};
if (uploadables) {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: request.name,
query: request.text,
variables: variables,
}),
);
const uploadableMap: {
[key: string]: string[];
} = {};
const uploadableKeys = Object.keys(uploadables);
uploadableKeys.forEach((key) => {
uploadableMap[key] = [`variables.${key}`];
});
formData.append("map", JSON.stringify(uploadableMap));
uploadableKeys.forEach((key) => {
formData.append(key, uploadables[key]);
});
requestInit.body = formData;
} else {
requestInit.headers = {
Accept: "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
};
requestInit.body = JSON.stringify({
operationName: request.name,
query: request.text,
variables,
});
}
const response = await fetch(endpoint, requestInit);
if (response.status === 500) {
throw new InternalServerError();
}
const json = await response.json();
if (json.errors) {
const errors = json.errors as GraphQLError[];
const unauthenticatedError = errors.find(hasUnauthenticatedError);
if (unauthenticatedError) {
throw new UnAuthenticatedError(unauthenticatedError.message);
}
const fullNameRequiredError = errors.find(hasFullNameRequiredError);
if (fullNameRequiredError) {
throw new FullNameRequiredError(fullNameRequiredError.message);
}
const assumptionRequiredError = errors.find(hasAssumptionRequiredError);
if (assumptionRequiredError) {
throw new AssumptionRequiredError(assumptionRequiredError.message);
}
const ndaSignatureRequiredError = errors.find(hasNDASignatureRequiredError);
if (ndaSignatureRequiredError) {
throw new NDASignatureRequiredError(ndaSignatureRequiredError.message);
}
const forbiddenError = errors.find(hasForbiddenError);
if (forbiddenError) {
throw new ForbiddenError(forbiddenError.message);
}
}
return json;
return async (request, variables, _, uploadables) => {
const requestInit: RequestInit = {
method: "POST",
credentials: "include",
headers: {},
};
if (uploadables) {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: request.name,
query: request.text,
variables: variables,
}),
);
const uploadableMap: {
[key: string]: string[];
} = {};
const uploadableKeys = Object.keys(uploadables);
uploadableKeys.forEach((key) => {
uploadableMap[key] = [`variables.${key}`];
});
formData.append("map", JSON.stringify(uploadableMap));
uploadableKeys.forEach((key) => {
formData.append(key, uploadables[key]);
});
requestInit.body = formData;
} else {
requestInit.headers = {
"Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
};
requestInit.body = JSON.stringify({
operationName: request.name,
query: request.text,
variables,
});
}
const response = await fetch(endpoint, requestInit);
if (response.status === 500) {
throw new InternalServerError();
}
const json = (await response.json()) as GraphQLResponse & {
errors?: GraphQLError[];
};
if (json.errors) {
const errors = json.errors;
const unauthenticatedError = errors.find(hasUnauthenticatedError);
if (unauthenticatedError) {
throw new UnAuthenticatedError(unauthenticatedError.message);
}
const fullNameRequiredError = errors.find(hasFullNameRequiredError);
if (fullNameRequiredError) {
throw new FullNameRequiredError(fullNameRequiredError.message);
}
const assumptionRequiredError = errors.find(hasAssumptionRequiredError);
if (assumptionRequiredError) {
throw new AssumptionRequiredError(assumptionRequiredError.message);
}
const ndaSignatureRequiredError = errors.find(hasNDASignatureRequiredError);
if (ndaSignatureRequiredError) {
throw new NDASignatureRequiredError(ndaSignatureRequiredError.message);
}
const forbiddenError = errors.find(hasForbiddenError);
if (forbiddenError) {
throw new ForbiddenError(forbiddenError.message);
}
}
return json;
};
};

View File

@@ -14,3 +14,8 @@
export { makeFetchQuery } from "./fetch";
export * from "./errors";
export {
createUseMutation,
type MutationFeedback,
type MutationNotifier,
} from "./useMutation";

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useCallback } from "react";
import { useMutation as useRelayMutation, type UseMutationConfig } from "react-relay";
import type {
GraphQLTaggedNode,
MutationParameters,
PayloadError,
} from "relay-runtime";
/**
* App-supplied surface for rendering mutation feedback. The shared hook owns
* *when* to notify; the host app owns *how* (toast system, i18n, error
* formatting), keeping this package free of UI and i18n dependencies.
*
* `notifyError` receives an optional title override; when omitted, the
* implementation supplies its own (localized) default.
*/
export type MutationNotifier = {
notifySuccess: (message: string) => void;
notifyError: (error: Error | PayloadError, title?: string) => void;
};
export type MutationFeedback = {
// Message shown on success. Omit for no success notification.
successMessage?: string;
// Error notification behavior: `true` (default) notifies with the notifier's
// default title, a string overrides that title, and `false` disables the
// automatic notification so the caller handles the rejected promise itself.
errorToast?: boolean | string;
};
/**
* Builds an awaitable `useMutation` hook bound to a host-provided notifier.
*
* The returned hook wraps react-relay's `useMutation` so that callers can
* `await` and continue only on success:
*
* - resolves with the mutation response on success;
* - preserves every UseMutationConfig option by spreading the caller's config;
* - on failure, notifies via the injected notifier (unless disabled) AND
* rejects.
*
* Each app calls this once with its own notifier hook and re-exports the
* result as the canonical `useMutation`.
*/
export function createUseMutation(useNotifier: () => MutationNotifier) {
return function useMutation<T extends MutationParameters>(
mutation: GraphQLTaggedNode,
feedback?: MutationFeedback,
) {
const [commit, isInFlight] = useRelayMutation<T>(mutation);
const notifier = useNotifier();
const { successMessage: baseSuccess, errorToast: baseErrorToast = true } = feedback ?? {};
const mutate = useCallback(
(config: UseMutationConfig<T>, overrides?: MutationFeedback): Promise<T["response"]> => {
const successMessage = overrides?.successMessage ?? baseSuccess;
const errorToast = overrides?.errorToast ?? baseErrorToast;
function notifyError(error: Error | PayloadError) {
if (errorToast === false) {
return;
}
notifier.notifyError(
error,
typeof errorToast === "string" ? errorToast : undefined,
);
}
return new Promise<T["response"]>((resolve, reject) => {
commit({
...config,
onCompleted: (response, errors) => {
config.onCompleted?.(response, errors);
if (errors && errors.length > 0) {
const [payloadError] = errors;
notifyError(payloadError);
reject(
payloadError instanceof Error
? payloadError
: new Error(payloadError.message),
);
return;
}
if (successMessage) {
notifier.notifySuccess(successMessage);
}
resolve(response);
},
onError: (error) => {
config.onError?.(error);
notifyError(error);
reject(error);
},
});
});
},
[commit, notifier, baseSuccess, baseErrorToast],
);
return [mutate, isInFlight] as const;
};
}

View File

@@ -18,7 +18,7 @@ import { type RouteObject } from "react-router";
export type AppRoute = Omit<RouteObject, "children"> & {
children?: AppRoute[];
Fallback?: ComponentType;
}
};
export function routeFromAppRoute(appRoute: AppRoute): RouteObject {
const { Component, Fallback, children, ...rest } = appRoute;

View File

@@ -12,5 +12,5 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export { routeFromAppRoute, type AppRoute } from "./appRoute";
export { withQueryRef, loaderFromQueryLoader } from "./relay";
export { type AppRoute, routeFromAppRoute } from "./appRoute";
export { loaderFromQueryLoader, withQueryRef } from "./relay";

View File

@@ -12,21 +12,31 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useCleanup } from "@probo/hooks";
import { type ComponentType } from "react";
import type { EnvironmentProviderOptions, PreloadedQuery } from "react-relay";
import { type LoaderFunction, type LoaderFunctionArgs, useLoaderData } from "react-router";
import { type OperationType } from "relay-runtime";
import { useCleanup } from "@probo/hooks";
// Infer the concrete `queryRef` type from a naked type position. Relay 21's
// first-party types model `PreloadedQuery#variables` as `VariablesOf<TQuery>`,
// which prevents inferring `TQuery` through it, so we infer the whole queryRef.
/**
* @deprecated Use a `*PageLoader` component with `useQueryLoader` +
* `usePreloadedQuery` instead. See contrib/claude/relay.md.
*
* Infer the concrete `queryRef` type from a naked type position. Relay 21's
* first-party types model `PreloadedQuery#variables` as `VariablesOf<TQuery>`,
* which prevents inferring `TQuery` through it, so we infer the whole queryRef.
*/
export function withQueryRef<
TQueryRef extends PreloadedQuery<OperationType>
TQueryRef extends PreloadedQuery<OperationType>,
>(
Component: ComponentType<{ queryRef: TQueryRef }>,
) {
return () => {
return function WithQueryRef() {
// `useLoaderData` is typed `any` (default generic), and its `SerializeFrom`
// generic would strip the `dispose` function type. Assert the loader's
// shape so the rest of the component stays type-safe; the assertion is not
// redundant despite the rule flagging it (the source is `any`).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const { queryRef, dispose } = useLoaderData() as {
queryRef: TQueryRef;
dispose: () => void;
@@ -34,15 +44,19 @@ export function withQueryRef<
useCleanup(dispose, 1000);
return <Component queryRef={queryRef} />
}
return <Component queryRef={queryRef} />;
};
}
/**
* @deprecated Use a `*PageLoader` component with `useQueryLoader` +
* `usePreloadedQuery` instead. See contrib/claude/relay.md.
*/
export function loaderFromQueryLoader<
TQuery extends OperationType,
TEnvironmentProviderOptions = EnvironmentProviderOptions
TEnvironmentProviderOptions = EnvironmentProviderOptions,
>(
queryLoader: (params: Record<string, string>) => PreloadedQuery<TQuery, TEnvironmentProviderOptions>
queryLoader: (params: Record<string, string>) => PreloadedQuery<TQuery, TEnvironmentProviderOptions>,
): LoaderFunction {
return ({ params }: LoaderFunctionArgs) => {
const query = queryLoader(params as Record<string, string>);
@@ -50,5 +64,5 @@ export function loaderFromQueryLoader<
queryRef: query,
dispose: query.dispose,
};
}
};
}