Skip to content

Commit

Permalink
fix: resolve issue with delegateMiddleware using outdated context in …
Browse files Browse the repository at this point in the history
…react (#598)

* fix: resolve issue with delegateMiddleware using outdated context in react

* fix: lint fix

* perf: remove handler on unmount
  • Loading branch information
MeetinaXD authored Nov 27, 2024
1 parent cf02da2 commit 15a99af
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-snakes-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'alova': patch
---

fix: resolve issue with delegateMiddleware using outdated context in react
1 change: 0 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
81 changes: 46 additions & 35 deletions packages/client/src/middlewares/actionDelegation.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { statesHookHelper } from '@/util/helper';
import {
$self,
createAssert,
falseValue,
filterItem,
forEach,
instanceOf,
isNumber,
isString,
objectKeys,
pushItem,
trueValue
objectValues,
pushItem
} from '@alova/shared';
import { AlovaGenerics, promiseStatesHook } from 'alova';
import {
Expand All @@ -19,7 +19,9 @@ import {
AlovaGuardNext
} from '~/typings/clienthook';

const actionsMap: Record<string | number | symbol, Actions[]> = {};
let currentHookIndex = 0;
// (id, (hookIndex, Actions))
const actionsMap: Record<string | number | symbol, Record<string | number, Actions>> = {};
const isFrontMiddlewareContext = <AG extends AlovaGenerics = AlovaGenerics, Args extends any[] = any[]>(
context: AlovaFrontMiddlewareContext<AG, Args> | AlovaFetcherMiddlewareContext<AG, Args>
): context is AlovaFrontMiddlewareContext<AG, Args> => !!(context as AlovaFrontMiddlewareContext<AG, Args>).send;
Expand All @@ -37,44 +39,53 @@ const assert = createAssert('subscriber');
export const actionDelegationMiddleware = <AG extends AlovaGenerics = AlovaGenerics, Args extends any[] = any[]>(
id: string | number | symbol
) => {
const { ref } = statesHookHelper(promiseStatesHook());
const { ref, onUnmounted } = statesHookHelper(promiseStatesHook());

const hookIndex = ref(currentHookIndex + 1);

if (hookIndex.current > currentHookIndex) {
currentHookIndex += 1;
}

onUnmounted(() => {
if (actionsMap[id]?.[hookIndex.current]) {
// delete action on unmount
delete actionsMap[id][hookIndex.current];
}
});

const delegated = ref(falseValue);
return (
context: (AlovaFrontMiddlewareContext<AG, Args> | AlovaFetcherMiddlewareContext<AG, Args>) & {
delegatingActions?: Actions;
},
next: AlovaGuardNext<AG, Args>
) => {
// The middleware will be called repeatedly. If you have already subscribed, you do not need to subscribe again.
if (!delegated.current) {
const { abort, proxyStates, delegatingActions = {} } = context;
const update = (newStates: Record<string, any>) => {
type ProxyStateKeys = keyof typeof proxyStates;
for (const key in newStates) {
proxyStates[key as ProxyStateKeys] && (proxyStates[key as ProxyStateKeys].v = newStates[key]);
const { abort, proxyStates, delegatingActions = {} } = context;
const update = (newStates: Record<string, any>) => {
type ProxyStateKeys = keyof typeof proxyStates;
for (const key in newStates) {
proxyStates[key as ProxyStateKeys] && (proxyStates[key as ProxyStateKeys].v = newStates[key]);
}
};
// Those with the same ID will be saved together in the form of an array
const hooks = (actionsMap[id] = actionsMap[id] || []);
const handler = isFrontMiddlewareContext(context)
? {
...delegatingActions,
send: context.send,
abort,
update
}
};
// Those with the same ID will be saved together in the form of an array
const handlersItems = (actionsMap[id] = actionsMap[id] || []);
handlersItems.push(
isFrontMiddlewareContext(context)
? {
...delegatingActions,
send: context.send,
abort,
update
}
: {
...delegatingActions,
fetch: context.fetch,
abort,
update
}
);
: {
...delegatingActions,
fetch: context.fetch,
abort,
update
};

hooks[hookIndex.current] = handler;

delegated.current = trueValue;
}
return next();
};
};
Expand All @@ -92,12 +103,12 @@ export const accessAction = (
) => {
const matched = [] as Actions[];
if (typeof id === 'symbol' || isString(id) || isNumber(id)) {
actionsMap[id] && pushItem(matched, ...actionsMap[id]);
actionsMap[id] && pushItem(matched, ...objectValues(actionsMap[id]));
} else if (instanceOf(id, RegExp)) {
forEach(
filterItem(objectKeys(actionsMap), idItem => id.test(idItem)),
idItem => {
pushItem(matched, ...actionsMap[idItem]);
pushItem(matched, ...objectValues(actionsMap[idItem]));
}
);
}
Expand All @@ -107,5 +118,5 @@ export const accessAction = (
assert(false, `no handler can be matched by using \`${id.toString()}\``);
}

forEach(matched, onMatch);
forEach(filterItem(matched, $self), onMatch);
};
158 changes: 158 additions & 0 deletions packages/client/test/react/actionDelegationMiddleware.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { accessAction, actionDelegationMiddleware, useWatcher } from '@/index';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createAlova } from 'alova';
import ReactHook from 'alova/react';
import { ReactElement, StrictMode, useState } from 'react';
import { mockRequestAdapter } from '~/test/mockData';

const StrictModeReact = StrictMode as any;

const alovaInst = createAlova({
baseURL: 'http://localhost:8080',
statesHook: ReactHook,
requestAdapter: mockRequestAdapter,
cacheLogger: false
});

describe('react => subscriber middleware', () => {
test('should reset context after state changes', async () => {
const successFn1 = vi.fn();
const successFn2 = vi.fn();
const request1 = vi.fn();
const request2 = vi.fn();

function Page() {
const [name1, setName1] = useState(0);
const [name2, setName2] = useState(0);
const methodInstance = (params: any) =>
alovaInst.Get('/info-list', {
params,
cacheFor: 0
});

const watcher1 = useWatcher(
() => {
request1(name1);
return methodInstance({ name: name1 });
},
[name1],
{
middleware: actionDelegationMiddleware('delegate')
}
);
const watcher2 = useWatcher(
() => {
request2(name2);
return methodInstance({ name: name2 });
},
[name2],
{
middleware: actionDelegationMiddleware('delegate')
}
);

watcher1.onSuccess(successFn1);
watcher2.onSuccess(successFn2);

return (
<div role="wrap">
<span role="status-1">{watcher1.loading ? 'loading' : 'loaded'}</span>
<span role="status-2">{watcher2.loading ? 'loading' : 'loaded'}</span>
<span role="name-1">{name1}</span>
<span role="name-2">{name2}</span>
<button
role="btnSend1"
onClick={() => setName1(v => v + 1)}>
send
</button>
<button
role="btnSend2"
onClick={() => setName2(v => v + 1)}>
send
</button>
</div>
);
}

render(
(
<StrictModeReact>
<Page />
</StrictModeReact>
) as ReactElement<any, any>
);
await waitFor(() => {
expect(screen.getByRole('status-1')).toHaveTextContent('loaded');
expect(screen.getByRole('status-2')).toHaveTextContent('loaded');
});

// name: 1 0.
fireEvent.click(screen.getByRole('btnSend1'));

await waitFor(() => {
expect(screen.getByRole('status-1')).toHaveTextContent('loaded');
expect(screen.getByRole('name-1')).toHaveTextContent('1');
expect(screen.getByRole('name-2')).toHaveTextContent('0');
expect(successFn1).toHaveBeenCalledTimes(1);
expect(successFn2).toHaveBeenCalledTimes(0);
expect(request1).toHaveBeenCalledWith(1);
});

// name: 1 1
fireEvent.click(screen.getByRole('btnSend2'));

await waitFor(() => {
expect(screen.getByRole('status-2')).toHaveTextContent('loaded');
expect(screen.getByRole('name-1')).toHaveTextContent('1');
expect(screen.getByRole('name-2')).toHaveTextContent('1');
expect(successFn1).toHaveBeenCalledTimes(1);
expect(successFn2).toHaveBeenCalledTimes(1);
expect(request2).toHaveBeenCalledWith(1);
});

// name: 1 1
// invoke hooks with middleware id `delegate`, should call watcher1.send and watcher2.send
accessAction('delegate', async ({ send }) => {
send({ fetch: 'delegate' });
});

await waitFor(() => {
expect(screen.getByRole('status-1')).toHaveTextContent('loaded');
expect(screen.getByRole('status-2')).toHaveTextContent('loaded');
expect(screen.getByRole('name-1')).toHaveTextContent('1');
expect(screen.getByRole('name-2')).toHaveTextContent('1');
expect(successFn1).toHaveBeenCalledTimes(2);
expect(successFn2).toHaveBeenCalledTimes(2);
expect(request1).toHaveBeenCalledWith(1);
expect(request2).toHaveBeenCalledWith(1);
});

// name: 1 2
fireEvent.click(screen.getByRole('btnSend2'));

await waitFor(() => {
expect(screen.getByRole('status-2')).toHaveTextContent('loaded');
expect(screen.getByRole('name-1')).toHaveTextContent('1');
expect(screen.getByRole('name-2')).toHaveTextContent('2');
expect(successFn1).toHaveBeenCalledTimes(2);
expect(successFn2).toHaveBeenCalledTimes(3);
});

// name: 1 2
// invoke hooks with middleware id `delegate`
accessAction('delegate', async ({ send }) => {
send({ fetch: 'delegate' });
});

await waitFor(() => {
expect(screen.getByRole('status-1')).toHaveTextContent('loaded');
expect(screen.getByRole('status-2')).toHaveTextContent('loaded');
expect(screen.getByRole('name-1')).toHaveTextContent('1');
expect(screen.getByRole('name-2')).toHaveTextContent('2');
expect(successFn1).toHaveBeenCalledTimes(3);
expect(successFn2).toHaveBeenCalledTimes(4);
expect(request1).toHaveBeenCalledWith(1);
expect(request2).toHaveBeenCalledWith(2);
});
});
});

0 comments on commit 15a99af

Please sign in to comment.