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:
@@ -10,12 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@probo/helpers": "1.0.0",
|
||||
"@probo/react-lazy": "1.0.0",
|
||||
"@probo/relay": "1.0.0",
|
||||
"@probo/routes": "1.0.0",
|
||||
"@probo/ui": "1.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-relay": "^21.0.1",
|
||||
"react-router": "^8.0.0",
|
||||
"relay-runtime": "^21.0.1"
|
||||
|
||||
53
apps/compliance-portal/src/lib/relay/useMutation.ts
Normal file
53
apps/compliance-portal/src/lib/relay/useMutation.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 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 { Toast } from "@base-ui/react/toast";
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { createUseMutation, type MutationNotifier } from "@probo/relay";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Binds the shared awaitable useMutation (`@probo/relay`) to this app's
|
||||
* feedback stack: Base UI toasts, i18next titles, and `formatError`
|
||||
* descriptions. This is the only place those opinions are wired.
|
||||
*
|
||||
* Always import useMutation from `#/lib/relay/useMutation` — never useMutation
|
||||
* from react-relay.
|
||||
*/
|
||||
function useMutationNotifier(): MutationNotifier {
|
||||
const toast = Toast.useToastManager();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo<MutationNotifier>(
|
||||
() => ({
|
||||
notifySuccess: (title) => {
|
||||
toast.add({ title, type: "success" });
|
||||
},
|
||||
notifyError: (error, title) => {
|
||||
const finalTitle = title ?? t("common.error");
|
||||
toast.add({
|
||||
title: finalTitle,
|
||||
description: formatError(finalTitle, error as GraphQLError),
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}),
|
||||
[toast, t],
|
||||
);
|
||||
}
|
||||
|
||||
export type { MutationFeedback } from "@probo/relay";
|
||||
|
||||
export const useMutation = createUseMutation(useMutationNotifier);
|
||||
@@ -344,7 +344,7 @@ pages/organizations/third-parties/_components/ThirdPartyContactListItem.tsx
|
||||
|
||||
## `_lib` folder
|
||||
|
||||
Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`).
|
||||
Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`). See [`contrib/claude/hooks.md`](hooks.md) for custom-hook conventions.
|
||||
|
||||
```text
|
||||
// Good — feature-scoped helpers under _lib
|
||||
@@ -356,6 +356,25 @@ pages/organizations/third-parties/
|
||||
ThirdPartyListItem.tsx
|
||||
```
|
||||
|
||||
## No barrel files
|
||||
|
||||
App code imports every module by its **explicit path** (`#/lib/relay/useMutation`, `#/lib/http/endpoint`). Do **not** add `index.ts` re-export barrels inside an app's `src/` to shorten import paths.
|
||||
|
||||
`index.ts` barrels are reserved for **package public entrypoints** (`packages/*`), where they define a real published API surface (`@probo/ui`, `@probo/relay`, …). An app has no published surface, so a barrel hides nothing — it only adds an indirection hop and invites Vite/tree-shaking and circular-import problems.
|
||||
|
||||
```text
|
||||
// Bad — barrel inside the app just to shorten a path
|
||||
src/lib/relay/
|
||||
index.ts # export { useMutation } from "./useMutation"
|
||||
useMutation.ts
|
||||
// import { useMutation } from "#/lib/relay"
|
||||
|
||||
// Good — import the module directly; no index.ts
|
||||
src/lib/relay/
|
||||
useMutation.ts
|
||||
// import { useMutation } from "#/lib/relay/useMutation"
|
||||
```
|
||||
|
||||
## `_locales` folder
|
||||
|
||||
Translations are i18next catalogs in a `_locales/` folder, **one file per locale**, named by locale tag: `en-US.json`, `fr-FR.json`. See [`contrib/claude/i18n.md`](i18n.md) for key conventions and setup.
|
||||
|
||||
158
contrib/claude/hooks.md
Normal file
158
contrib/claude/hooks.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Custom hooks
|
||||
|
||||
Custom hooks encapsulate reusable behavior (data wiring, derived state, event logic). This guide covers **where they live**, **how they are named**, and the **mutation hook pattern** — an awaitable wrapper over Relay's `useMutation` that preserves every option and automates error feedback.
|
||||
|
||||
## Related guides
|
||||
|
||||
| Topic | Guide |
|
||||
|-------|--------|
|
||||
| `_lib` / `_components` placement, hoisting | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
|
||||
| Mutations, store updates, connection directives | [`contrib/claude/relay.md`](relay.md) |
|
||||
| Toasts, user feedback | [`contrib/claude/ui.md`](ui.md#user-feedback-toasts) |
|
||||
| i18next translation keys | [`contrib/claude/i18n.md`](i18n.md) |
|
||||
|
||||
## Placement
|
||||
|
||||
- **Feature-scoped hooks** live in the feature's `_lib/` folder, next to the pages that use them (`pages/organizations/measures/_lib/useDeleteMeasure.ts`).
|
||||
- **Shared hooks** used across features are hoisted to the **nearest common ancestor's** `_lib/`, and only **app-wide** primitives (used everywhere) live in the top-level `src/lib/`.
|
||||
- Promote a hook **when a second feature needs it**, not preemptively — the same rule as `_components/` (see [`app-arborescence.md`](app-arborescence.md)).
|
||||
|
||||
```text
|
||||
// Feature-scoped
|
||||
pages/organizations/measures/_lib/useDeleteMeasure.ts
|
||||
|
||||
// Shared across a feature area
|
||||
pages/organizations/_lib/useOrganizationId.ts
|
||||
|
||||
// App-wide primitive
|
||||
src/lib/relay/useMutation.ts
|
||||
```
|
||||
|
||||
## Shape and naming
|
||||
|
||||
- **One primary hook per file**; the file is camelCase and matches the hook name (`useDeleteMeasure.ts` → `useDeleteMeasure`).
|
||||
- Hooks are **`function` declarations**, named `use…` (see [`react-components.md`](react-components.md#component-shape)).
|
||||
- Colocate a hook's `graphql` operation in the same file.
|
||||
- A hook returns either a value, or a tuple when it mirrors a React/Relay primitive (`[commit, isInFlight]`).
|
||||
|
||||
## Mutation hooks
|
||||
|
||||
All mutations go through the shared **`useMutation`** primitive. Its mechanics live in `@probo/relay` as the `createUseMutation(useNotifier)` factory, and each app binds it once in `src/lib/relay/useMutation.ts`. It wraps Relay's `useMutation` to:
|
||||
|
||||
1. Return an **awaitable** commit that resolves with the mutation **response** (so callers can `await` and continue only on success).
|
||||
2. **Preserve every `UseMutationConfig` option** (`variables`, `connections`, `updater`, `optimisticResponse`, `onCompleted`, `onError`, …) by spreading the caller's config.
|
||||
3. **Automate feedback**: on failure it notifies (via the app's injected `MutationNotifier` — Base UI toast + `formatError`) **and** rejects the promise — controllable per call through a `MutationFeedback` options object.
|
||||
|
||||
### Always import `useMutation` from `#/lib/relay/useMutation`
|
||||
|
||||
Our `useMutation` intentionally shadows `react-relay`'s. **Import it only from `#/lib/relay/useMutation`; never import `useMutation` from `react-relay` directly** (enforced in compliance-portal by a `no-restricted-imports` ESLint rule). This guarantees one consistent entrypoint with awaitable results and automatic error handling everywhere.
|
||||
|
||||
```ts
|
||||
// Bad — raw Relay hook (no await, no auto error handling)
|
||||
import { useMutation } from "react-relay";
|
||||
|
||||
// Good — the project primitive
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
```
|
||||
|
||||
### The primitive: shared factory + app binding
|
||||
|
||||
The factory lives in `@probo/relay` and stays free of UI/i18n dependencies — it delegates rendering to an injected `MutationNotifier` (`createUseMutation` source: [`packages/relay/src/useMutation.ts`](../../packages/relay/src/useMutation.ts)). The app binds it once to its own toast + i18n + `formatError` stack:
|
||||
|
||||
```ts
|
||||
// src/lib/relay/useMutation.ts — the only place feedback is wired
|
||||
import { Toast } from "@base-ui/react/toast";
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { createUseMutation, type MutationNotifier } from "@probo/relay";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function useMutationNotifier(): MutationNotifier {
|
||||
const toast = Toast.useToastManager();
|
||||
const { t } = useTranslation();
|
||||
return useMemo<MutationNotifier>(
|
||||
() => ({
|
||||
notifySuccess: (title) => toast.add({ title, type: "success" }),
|
||||
notifyError: (error, title) => {
|
||||
const finalTitle = title ?? t("common.error");
|
||||
toast.add({
|
||||
title: finalTitle,
|
||||
description: formatError(finalTitle, error as GraphQLError),
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}),
|
||||
[toast, t],
|
||||
);
|
||||
}
|
||||
|
||||
export type { MutationFeedback } from "@probo/relay";
|
||||
|
||||
export const useMutation = createUseMutation(useMutationNotifier);
|
||||
```
|
||||
|
||||
### Domain mutation hook (colocated `_lib/`)
|
||||
|
||||
A feature hook wraps the primitive with its operation and default feedback. Name the hook after the action; name the destructured commit function after the tagged node minus `Mutation` (see [`relay.md`](relay.md#naming-convention)).
|
||||
|
||||
```ts
|
||||
// pages/organizations/measures/_lib/useDeleteMeasure.ts
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
import type { DeleteMeasureMutation } from "#/__generated__/core/DeleteMeasureMutation.graphql";
|
||||
|
||||
const deleteMeasureMutation = graphql`
|
||||
mutation DeleteMeasureMutation($input: DeleteMeasureInput!, $connections: [ID!]!) {
|
||||
deleteMeasure(input: $input) {
|
||||
deletedMeasureId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useDeleteMeasure() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<DeleteMeasureMutation>(deleteMeasureMutation, {
|
||||
successMessage: t("measures.deleted"),
|
||||
errorToast: t("measures.deleteFailed"),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Usage — await the result
|
||||
|
||||
```tsx
|
||||
const [deleteMeasure, isDeleting] = useDeleteMeasure();
|
||||
|
||||
// Default: awaits the response; on failure it toasts AND throws.
|
||||
async function onConfirm() {
|
||||
await deleteMeasure({ variables: { input: { measureId }, connections: [connectionId] } });
|
||||
navigate(".."); // only runs on success
|
||||
}
|
||||
|
||||
// Opt out of the auto-toast to handle the error yourself:
|
||||
try {
|
||||
const result = await deleteMeasure({ variables }, { errorToast: false });
|
||||
// use result.deleteMeasure.deletedMeasureId …
|
||||
} catch (error) {
|
||||
// custom handling
|
||||
}
|
||||
```
|
||||
|
||||
### Do / don't
|
||||
|
||||
```text
|
||||
// Bad — legacy wrappers (removed in v2)
|
||||
useMutationWithToasts(...) // resolves to void, loses the response; v1 toast + __
|
||||
useMutationWithIncrement(...) // callback-style, not awaitable
|
||||
promisifyMutation(commit) // standalone wrapper, disconnected from the hook
|
||||
|
||||
// Bad — importing the raw Relay hook
|
||||
import { useMutation } from "react-relay";
|
||||
|
||||
// Good — one primitive, awaitable, options preserved, auto error handling
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
```
|
||||
|
||||
Prefer the declarative store directives (`@appendEdge`, `@deleteEdge`) and the patterns in [`relay.md`](relay.md) for *what* the mutation does to the store; this hook only governs *how* it is invoked and how its errors surface.
|
||||
@@ -288,7 +288,34 @@ Every mutation **must** update the Relay store so the UI reflects changes immedi
|
||||
|
||||
### `useMutation`
|
||||
|
||||
Direct Relay hook for simple cases.
|
||||
The project mutation primitive — awaitable, preserves every `UseMutationConfig` option, and routes success/error feedback through an injected notifier.
|
||||
|
||||
> Import `useMutation` from `#/lib/relay/useMutation`, never from `react-relay`. In compliance-portal this is enforced by a `no-restricted-imports` ESLint rule. See [`contrib/claude/hooks.md`](hooks.md#mutation-hooks).
|
||||
|
||||
#### Shared hook, app binding
|
||||
|
||||
The mechanics live in `@probo/relay` as `createUseMutation(useNotifier)` — a factory that wraps `react-relay`'s `useMutation` (promise wrapping, `onCompleted`/`onError` dispatch, `errorToast` semantics) but knows nothing about toasts or i18n. Each app binds it once to its own feedback stack via a `MutationNotifier` and re-exports the result as the canonical `useMutation`:
|
||||
|
||||
```tsx
|
||||
// apps/compliance-portal/src/lib/relay/useMutation.ts — the only place feedback is wired
|
||||
import { createUseMutation, type MutationNotifier } from "@probo/relay";
|
||||
|
||||
function useMutationNotifier(): MutationNotifier {
|
||||
const toast = Toast.useToastManager();
|
||||
const { t } = useTranslation();
|
||||
return useMemo<MutationNotifier>(() => ({
|
||||
notifySuccess: (title) => toast.add({ title, type: "success" }),
|
||||
notifyError: (error, title) => {
|
||||
const finalTitle = title ?? t("common.error");
|
||||
toast.add({ title: finalTitle, description: formatError(finalTitle, error as GraphQLError), type: "error" });
|
||||
},
|
||||
}), [toast, t]);
|
||||
}
|
||||
|
||||
export const useMutation = createUseMutation(useMutationNotifier);
|
||||
```
|
||||
|
||||
This keeps `@probo/relay` free of UI and i18n dependencies (the toast system, `react-i18next`, and `formatError` stay in the app), while the awaitable behavior is shared. Pass `MutationFeedback` (`successMessage`, `errorToast`) to control notifications without writing `onCompleted`/`onError` by hand.
|
||||
|
||||
#### Naming convention
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
||||
// sets below; everything else is ignored so a bare `eslint .` keeps the same
|
||||
// scope as the previous per-workspace configs.
|
||||
const appDirs = ["apps/console/**", "apps/trust/**", "apps/compliance-portal/**"];
|
||||
const reactDirs = [...appDirs, "packages/ui/**"];
|
||||
const reactDirs = [...appDirs, "packages/ui/**", "packages/relay/**", "packages/routes/**"];
|
||||
const lintedDirs = [...reactDirs, "packages/eslint-config/**"];
|
||||
|
||||
export default defineConfig([
|
||||
@@ -25,8 +25,6 @@ export default defineConfig([
|
||||
"packages/n8n-node/**",
|
||||
"packages/prosemirror/**",
|
||||
"packages/react-lazy/**",
|
||||
"packages/relay/**",
|
||||
"packages/routes/**",
|
||||
"packages/tsconfig/**",
|
||||
]),
|
||||
{
|
||||
@@ -48,6 +46,28 @@ export default defineConfig([
|
||||
files: appDirs,
|
||||
extends: [configs.relay],
|
||||
},
|
||||
{
|
||||
// compliance-portal mutates through the awaitable useMutation bound in
|
||||
// #/lib/relay/useMutation (over @probo/relay's createUseMutation), never
|
||||
// react-relay's useMutation directly. Scoped to this app only: console and
|
||||
// trust still use react-relay's useMutation.
|
||||
files: ["apps/compliance-portal/**"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "react-relay",
|
||||
importNames: ["useMutation"],
|
||||
message:
|
||||
"Use useMutation from #/lib/relay/useMutation, not react-relay.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: reactDirs,
|
||||
ignores: ["packages/ui/tailwind.config.js"],
|
||||
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -29,12 +29,15 @@
|
||||
"name": "@probo/compliance-portal",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@probo/helpers": "1.0.0",
|
||||
"@probo/react-lazy": "1.0.0",
|
||||
"@probo/routes": "1.0.0",
|
||||
"@probo/ui": "1.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-relay": "^21.0.1",
|
||||
"react-router": "^8.0.0",
|
||||
"relay-runtime": "^21.0.1"
|
||||
@@ -570,6 +573,66 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@base-ui/react": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz",
|
||||
"integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@base-ui/utils": "0.3.1",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@types/react": "^17 || ^18 || ^19",
|
||||
"date-fns": "^4.0.0",
|
||||
"react": "^17 || ^18 || ^19",
|
||||
"react-dom": "^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@date-fns/tz": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@base-ui/utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz",
|
||||
"integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"reselect": "^5.2.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17 || ^18 || ^19",
|
||||
"react": "^17 || ^18 || ^19",
|
||||
"react-dom": "^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
@@ -12575,6 +12638,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
@@ -12655,6 +12727,34 @@
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ibm-cloud-sdk-core": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.5.0.tgz",
|
||||
@@ -17360,6 +17460,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-intersection-observer": {
|
||||
"version": "9.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
|
||||
@@ -17793,6 +17920,12 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -19335,7 +19468,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -20380,6 +20513,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,3 +14,8 @@
|
||||
|
||||
export { makeFetchQuery } from "./fetch";
|
||||
export * from "./errors";
|
||||
export {
|
||||
createUseMutation,
|
||||
type MutationFeedback,
|
||||
type MutationNotifier,
|
||||
} from "./useMutation";
|
||||
|
||||
117
packages/relay/src/useMutation.ts
Normal file
117
packages/relay/src/useMutation.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user