Skip to content

mutations are typed correctly #8122

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 11, 2025
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
66 changes: 21 additions & 45 deletions apps/desktop/src/lib/baseBranch/baseBranchService.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Code } from '$lib/backend/ipc';
import { BaseBranch, type RemoteBranchInfo } from '$lib/baseBranch/baseBranch';
import { showError } from '$lib/notifications/toasts';
import { isTauriCommandError } from '$lib/state/backendQuery';
import { invalidatesList, providesList, ReduxTag } from '$lib/state/tags';
import { parseRemoteUrl } from '$lib/url/gitUrl';
import { plainToInstance } from 'class-transformer';
Expand Down Expand Up @@ -65,55 +66,30 @@ export default class BaseBranchService {
}

async fetchFromRemotes(projectId: string, action?: string) {
return await this.api.endpoints.fetchFromRemotes.mutate(
{ projectId, action },
{
onError: (error, { action }) => {
const { code } = error;
if (code === Code.DefaultTargetNotFound) {
// Swallow this error since user should be taken to project setup page
return;
}

if (code === Code.ProjectsGitAuth) {
showError('Failed to authenticate', error.message);
return;
}

if (action !== undefined) {
showError('Failed to fetch', error.message);
}

console.error(error);
return await this.api.endpoints.fetchFromRemotes
.mutate({ projectId, action })
.catch((error: unknown) => {
if (!isTauriCommandError(error)) {
showError('Failed to fetch', String(error));
return;
}
const { code } = error;
if (code === Code.DefaultTargetNotFound) {
// Swallow this error since user should be taken to project setup page
return;
}
}
);
}

async refreshRemotes(projectId: string) {
await this.api.endpoints.fetchFromRemotes.mutate(
{ projectId },
{
onError: (error, { action }) => {
const { code } = error;
if (code === Code.DefaultTargetNotFound) {
// Swallow this error since user should be taken to project setup page
return;
}

if (code === Code.ProjectsGitAuth) {
showError('Failed to authenticate', error.message);
return;
}

if (action !== undefined) {
showError('Failed to fetch', error.message);
}
if (code === Code.ProjectsGitAuth) {
showError('Failed to authenticate', error.message);
return;
}

console.error(error);
if (action !== undefined) {
showError('Failed to fetch', error.message);
}
}
);

console.error(error);
});
}

get setTarget() {
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/state/backendQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ type ApiArgs = {

export type TauriCommandError = { message: string; code?: string };

export function isTauriCommandError(something: unknown): something is TauriCommandError {
return (
!!something &&
typeof something === 'object' &&
something !== null &&
'message' in something &&
typeof (something as TauriCommandError).message === 'string' &&
('code' in something ? typeof (something as TauriCommandError).code === 'string' : true)
);
}

/**
* Typeguard for accessing injected Tauri dependency safely.
*/
Expand Down
38 changes: 1 addition & 37 deletions apps/desktop/src/lib/state/butlerModule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
buildMutationHooks,
buildQueryHooks,
type UseMutationHookParams
type MutationHooks
} from '$lib/state/customHooks.svelte';
import { isMutationDefinition, isQueryDefinition } from '$lib/state/helpers';
import { type Reactive } from '@gitbutler/shared/storeUtils';
Expand All @@ -17,13 +17,11 @@ import {
type MutationDefinition,
type QueryResultSelectorResult,
type ApiModules,
type MutationResultSelectorResult,
type QueryActionCreatorResult,
type StartQueryActionCreatorOptions
} from '@reduxjs/toolkit/query';
import type { tauriBaseQuery, TauriBaseQueryFn } from '$lib/state/backendQuery';
import type { HookContext } from '$lib/state/context';
import type { Prettify } from '@gitbutler/shared/utils/typeUtils';

/** Gives our module a namespace in the extended `ApiModules` interface. */
const butlerModuleName = Symbol();
Expand Down Expand Up @@ -213,37 +211,3 @@ type QueryHooks<D extends CustomQuery<unknown>> = {
CustomResult<CustomQuery<T extends Transformer<D> ? ReturnType<T> : ResultTypeFrom<D>>>[]
>;
};

export type CustomMutationResult<Definition extends MutationDefinition<any, any, string, any>> =
Prettify<MutationResultSelectorResult<Definition>>;

type CustomMutation<Definition extends MutationDefinition<any, any, string, any>> = readonly [
/**
* Trigger the mutation with the given arguments.
*
* If awaited, the result will contain the mutation result.
*/
(args: QueryArgFrom<Definition>) => Promise<NonNullable<ResultTypeFrom<Definition>>>,
/**
* The reactive state of the mutation.
*
* This contains the result (if any yet) of the mutation plus additional information about its state.
*/
Reactive<CustomMutationResult<Definition>>,
/**
* A method to reset the hook back to its original state and remove the current result from the cache.
*/
() => void
];

/**
* Declaration of custom methods for mutations.
*/
type MutationHooks<Definition extends MutationDefinition<unknown, any, string, unknown>> = {
/** Execute query and return results. */
useMutation: (params?: UseMutationHookParams<Definition>) => Prettify<CustomMutation<Definition>>;
mutate: (
args: QueryArgFrom<Definition>,
options?: UseMutationHookParams<Definition>
) => Promise<NonNullable<ResultTypeFrom<Definition>>>;
};
124 changes: 89 additions & 35 deletions apps/desktop/src/lib/state/customHooks.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { reactive } from '@gitbutler/shared/storeUtils';
import { isTauriCommandError, type TauriCommandError } from '$lib/state/backendQuery';
import { reactive, type Reactive } from '@gitbutler/shared/storeUtils';
import {
type Api,
type ApiEndpointMutation,
Expand All @@ -7,15 +8,16 @@ import {
type EndpointDefinitions,
type MutationActionCreatorResult,
type MutationDefinition,
type MutationResultSelectorResult,
type QueryActionCreatorResult,
type QueryArgFrom,
type ResultTypeFrom,
type RootState,
type StartQueryActionCreatorOptions
} from '@reduxjs/toolkit/query';
import type { TauriCommandError } from '$lib/state/backendQuery';
import type { CustomQuery } from '$lib/state/butlerModule';
import type { HookContext } from '$lib/state/context';
import type { Prettify } from '@gitbutler/shared/utils/typeUtils';

type TranformerFn = (data: any, args: any) => any;

Expand Down Expand Up @@ -164,48 +166,99 @@ export function buildQueryHooks<Definitions extends EndpointDefinitions>({

export type UseMutationHookParams<Definition extends MutationDefinition<any, any, string, any>> = {
fixedCacheKey?: string;
/**
* A callback to be called when the trigger has been pulled, but before the mutation has been dispatched.
*/
preEffect?: (queryArgs: QueryArgFrom<Definition>) => void;
/**
* A callback to be called when the mutation is successful.
*/
sideEffect?: (data: ResultTypeFrom<Definition>, queryArgs: QueryArgFrom<Definition>) => void;
/**
* A callback to be called when the mutation fails.
*
* This does not stop the error from being thrown, but allows you to add a side effect depending on the error.
*/
onError?: (error: TauriCommandError, queryArgs: QueryArgFrom<Definition>) => void;
};

export type CustomMutationResult<Definition extends MutationDefinition<any, any, string, any>> =
Prettify<MutationResultSelectorResult<Definition>>;

type CustomMutation<Definition extends MutationDefinition<any, any, string, any>> = readonly [
/**
* Trigger the mutation with the given arguments.
*
* If awaited, the result will contain the mutation result.
*/
(args: QueryArgFrom<Definition>) => Promise<ResultTypeFrom<Definition>>,
/**
* The reactive state of the mutation.
*
* This contains the result (if any yet) of the mutation plus additional information about its state.
*/
Reactive<CustomMutationResult<Definition>>,
/**
* A method to reset the hook back to its original state and remove the current result from the cache.
*/
() => void
];

/**
* Declaration of custom methods for mutations.
*/
export interface MutationHooks<
Definition extends MutationDefinition<unknown, any, string, unknown>
> {
/**
* Mutation hook.
*
* Returns a function to trigger the mutation, a reactive state of the mutation and a function to reset it.
* */
useMutation: (params?: UseMutationHookParams<Definition>) => Prettify<CustomMutation<Definition>>;
/**
* Execute query and return results.
*/
mutate(
args: QueryArgFrom<Definition>,
options?: UseMutationHookParams<Definition>
): Promise<ResultTypeFrom<Definition>>;
}

/**
* Returns implementations for custom endpoint methods defined in `ButlerModule`.
*/
export function buildMutationHooks<Definitions extends EndpointDefinitions>({
export function buildMutationHooks<
Definitions extends EndpointDefinitions,
D extends MutationDefinition<unknown, any, string, unknown>
>({
api,
endpointName,
ctx: { getState, getDispatch }
}: {
api: Api<any, Definitions, any, any, CoreModule>;
endpointName: string;
ctx: HookContext;
}) {
}): MutationHooks<D> {
const endpoint = api.endpoints[endpointName]!;
const state = getState() as any as () => RootState<any, any, any>;

const { initiate, select } = endpoint as ApiEndpointMutation<
MutationDefinition<any, any, any, any, any>,
Definitions
>;

async function mutate(
queryArg: unknown,
options?: UseMutationHookParams<MutationDefinition<any, any, any, any, any>>
) {
const { initiate, select } = endpoint as unknown as ApiEndpointMutation<D, Definitions>;
async function mutate(queryArg: QueryArgFrom<D>, options?: UseMutationHookParams<D>) {
const dispatch = getDispatch();
const { fixedCacheKey, sideEffect, preEffect, onError } = options ?? {};
preEffect?.(queryArg);
const result = await dispatch(initiate(queryArg, { fixedCacheKey }));
if (!result.error) {
sideEffect?.(result.data, queryArg);
}

if (result.error && onError) {
onError(result.error, queryArg);
const dispatchResult = dispatch(initiate(queryArg, { fixedCacheKey }));
try {
const result = await dispatchResult.unwrap();
sideEffect?.(result, queryArg);
return result;
} catch (error: unknown) {
if (onError && isTauriCommandError(error)) {
onError(error, queryArg);
}
throw error;
}
Comment on lines +253 to +260
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return the mutation result or throw


return result;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't return the dispatch result


/**
Expand All @@ -217,24 +270,25 @@ export function buildMutationHooks<Definitions extends EndpointDefinitions>({
* Replicate the behavior of `useMutation` from RTK Query.
* @see: https://github.com/reduxjs/redux-toolkit/blob/637b0cad2b227079ccd0c5a3073c09ace6d8759e/packages/toolkit/src/query/react/buildHooks.ts#L867-L935
*/
function useMutation(
params?: UseMutationHookParams<MutationDefinition<any, any, any, any, any>>
) {
function useMutation(params?: UseMutationHookParams<D>) {
const { fixedCacheKey, preEffect, sideEffect, onError } = params || {};
const dispatch = getDispatch();

let promise =
$state<MutationActionCreatorResult<MutationDefinition<unknown, any, any, unknown>>>();
let promise = $state<MutationActionCreatorResult<D>>();

async function triggerMutation(queryArg: unknown) {
async function triggerMutation(queryArg: QueryArgFrom<D>) {
preEffect?.(queryArg);
const dispatchResult = dispatch(initiate(queryArg, { fixedCacheKey }));
promise = dispatchResult;
const result = await promise.unwrap().catch((error) => {
onError?.(error, queryArg);
});
sideEffect?.(result, queryArg);
Comment on lines -232 to -235
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't swallow the error

return result;
promise = dispatch(initiate(queryArg, { fixedCacheKey }));
try {
const result = await promise.unwrap();
sideEffect?.(result, queryArg);
return result;
} catch (error: unknown) {
if (onError && isTauriCommandError(error)) {
onError(error, queryArg);
}
throw error;
}
Comment on lines +286 to +290
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On error is called, but the error is thrown still

}

function reset() {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/routes/[projectId]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
});

async function fetchRemoteForProject() {
await baseBranchService.refreshRemotes(projectId);
await baseBranchService.fetchFromRemotes(projectId);
}

function setupFetchInterval() {
Expand Down
Loading