diff --git a/.changeset/shiny-pigs-pretend.md b/.changeset/shiny-pigs-pretend.md new file mode 100644 index 00000000..34e8211d --- /dev/null +++ b/.changeset/shiny-pigs-pretend.md @@ -0,0 +1,6 @@ +--- +'@alova/shared': patch +'alova': patch +--- + +return response data in `middleware.next` of `useSQRequest` diff --git a/.changeset/thin-zebras-return.md b/.changeset/thin-zebras-return.md new file mode 100644 index 00000000..b5de94fe --- /dev/null +++ b/.changeset/thin-zebras-return.md @@ -0,0 +1,5 @@ +--- +'@alova/mock': patch +--- + +fix that throw error when return null in mock api diff --git a/internal/jest.setup.mock.ts b/internal/jest.setup.mock.ts index a663955c..1a4d590c 100644 --- a/internal/jest.setup.mock.ts +++ b/internal/jest.setup.mock.ts @@ -15,9 +15,9 @@ Object.defineProperties(globalThis, { } }); -// if the environment is jsdom, set process.browser to true +// if the environment is jsdom, set process.cwd to undefined if (typeof window !== 'undefined') { - (process as any).browser = true; + (process as any).cwd = undefined; } // undici must import after defining TextDecoder and TextEncoder, otherwise it will throw error diff --git a/packages/adapter-mock/src/MockRequest.ts b/packages/adapter-mock/src/MockRequest.ts index 56c80966..9544bd03 100644 --- a/packages/adapter-mock/src/MockRequest.ts +++ b/packages/adapter-mock/src/MockRequest.ts @@ -1,5 +1,5 @@ -import { isFn, isNumber, isString, noop } from '@alova/shared/function'; -import { falseValue, trueValue, undefinedValue } from '@alova/shared/vars'; +import { isFn, isNumber, isString, newInstance, usePromise } from '@alova/shared/function'; +import { falseValue, promiseReject, promiseResolve, trueValue, undefinedValue } from '@alova/shared/vars'; import type { AlovaGenerics, Method, RequestElements } from 'alova'; import { Mock, MockRequestInit } from '~/typings'; import consoleRequestInfo from './consoleRequestInfo'; @@ -103,44 +103,45 @@ export default function MockRequest( }); return httpAdapter(elements, method); } - throw new Error(`could not find the httpAdapter which send request.\n[url]${url}`); + throw new Error(`cannot find the httpAdapter.\n[url]${url}`); } - let timer: NodeJS.Timeout; - let rejectFn: (reason?: any) => void = noop; + const promiseResolver = usePromise(); + const { resolve } = promiseResolver; + let { promise: resonpsePromise, reject } = promiseResolver; const timeout = method.config.timeout || 0; if (timeout > 0) { setTimeout(() => { - rejectFn(new Error('request timeout')); + reject(new Error('request timeout')); }, timeout); } - const resonpsePromise = new Promise((resolve, reject) => { - rejectFn = reject; - timer = setTimeout(() => { - // response支持返回promise对象 - try { - const res = isFn(mockDataRaw) - ? mockDataRaw({ - query, - params, - data: isString(data) || !data ? {} : data, - headers: requestHeaders - }) - : mockDataRaw; - // 这段代码表示,将内部reject赋值到外部,如果超时了则立即触发reject,或者等待res(如果res为promise)resolve - resolve( - new Promise((resolveInner, rejectInner) => { - rejectFn = rejectInner; - Promise.resolve(res).then(resolveInner).catch(rejectInner); + const timer = setTimeout(() => { + // response支持返回promise对象 + try { + const res = isFn(mockDataRaw) + ? mockDataRaw({ + query, + params, + data: isString(data) || !data ? {} : data, + headers: requestHeaders }) - ); - } catch (error) { - reject(error); - } - }, delay); - }) - .then(response => { + : mockDataRaw; + + // 这段代码表示,将内部reject赋值到外部,如果超时了则立即触发reject,或者等待res(如果res为promise)resolve + resolve( + newInstance(Promise, (resolveInner, rejectInner) => { + reject = rejectInner; + promiseResolve(res).then(resolveInner).catch(rejectInner); + }) + ); + } catch (error) { + reject(error); + } + }, delay); + + resonpsePromise = resonpsePromise + .then((response: any) => { let status = 200; let statusText = 'ok'; let responseHeaders = {}; @@ -150,7 +151,7 @@ export default function MockRequest( if (response === undefinedValue) { status = 404; statusText = 'api not found'; - } else if (isNumber(response.status) && isString(response.statusText)) { + } else if (response && isNumber(response.status) && isString(response.statusText)) { // 返回了自定义状态码和状态文本,将它作为响应信息 status = response.status; statusText = response.statusText; @@ -161,42 +162,51 @@ export default function MockRequest( body = response; } - // 打印模拟数据请求信息 - isFn(mockRequestLogger) && - mockRequestLogger({ - isMock: trueValue, - url, - method: type, - params, - headers: requestHeaders, - query, - data: (data as any) || {}, - responseHeaders, - response: body - }); - return onMockResponse( - { status, statusText, responseHeaders, body }, - { - headers: requestHeaders, - query, - params, - data: (data as any) || {} - }, - method - ); + return newInstance(Promise, (resolve, reject) => { + try { + const res = onMockResponse( + { status, statusText, responseHeaders, body }, + { + headers: requestHeaders, + query, + params, + data: (data as any) || {} + }, + method + ); + resolve(res); + } catch (error) { + reject(error); + } + }).then(response => { + // 打印模拟数据请求信息 + isFn(mockRequestLogger) && + mockRequestLogger({ + isMock: trueValue, + url, + method: type, + params, + headers: requestHeaders, + query, + data: (data as any) || {}, + responseHeaders, + response: body + }); + return response; + }); }) - .catch(error => Promise.reject(onMockError(error, method))); + .catch(error => promiseReject(onMockError(error, method))); // 返回响应数据 return { response: () => resonpsePromise.then(({ response }) => - (response as any).toString() === '[object Response]' ? (response as any).clone() : response + response && response.toString() === '[object Response]' ? (response as any).clone() : response ), headers: () => resonpsePromise.then(({ headers }) => headers), abort: () => { clearTimeout(timer); - rejectFn(new Error('The user abort request')); + reject(new Error('The user abort request')); } }; }; diff --git a/packages/adapter-mock/test/mockRequest.spec.ts b/packages/adapter-mock/test/mockRequest.spec.ts index 4dcadcd5..21d48e95 100644 --- a/packages/adapter-mock/test/mockRequest.spec.ts +++ b/packages/adapter-mock/test/mockRequest.spec.ts @@ -66,42 +66,33 @@ describe('mock request', () => { ) .send(); expect(payload).toStrictEqual({ id: 1 }); - expect(mockApi).toBeCalled(); - expect(mockResponse).toBeCalled(); + expect(mockApi).toHaveBeenCalled(); + expect(mockResponse).toHaveBeenCalled(); }); - test('should receive all request data', async () => { + test('should call `mockRequestLogger` and receive all request data', async () => { const mocks = defineMock({ '[POST]/detail': () => ({ id: 1 - }) + }), + '[POST]/detail2': null }); const mockFn = jest.fn(); // 模拟数据请求适配器 const mockRequestAdapter = createAlovaMockAdapter([mocks], { delay: 10, - onMockResponse: responseData => ({ - response: responseData.body, - headers: {} - }), - mockRequestLogger: ({ isMock, url, method, headers, query, data, responseHeaders, response }) => { - mockFn(); - expect(isMock).toBeTruthy(); - expect(url).toBe('http://xxx/detail?aa=1&bb=2'); - expect(method).toBe('POST'); - expect(headers).toStrictEqual({ - customHeader: 1 - }); - expect(query).toStrictEqual({ - aa: '1', - bb: '2' - }); - expect(data).toStrictEqual({}); - expect(responseHeaders).toStrictEqual({}); - expect(response).toStrictEqual({ - id: 1 - }); + onMockResponse: (responseData, _, method) => { + if (method.url === '/detail2') { + throw new Error('response error'); + } + return { + response: responseData.body, + headers: {} + }; + }, + mockRequestLogger: logger => { + mockFn(logger); } }); @@ -109,19 +100,38 @@ describe('mock request', () => { baseURL: 'http://xxx', requestAdapter: mockRequestAdapter }); - const payload = await alovaInst - .Post( - '/detail?aa=1&bb=2', - {}, - { - headers: { - customHeader: 1 - } + const payload = await alovaInst.Post( + '/detail?aa=1&bb=2', + {}, + { + headers: { + customHeader: 1 } - ) - .send(); + } + ); expect(payload).toStrictEqual({ id: 1 }); - expect(mockFn).toBeCalled(); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith({ + isMock: true, + url: 'http://xxx/detail?aa=1&bb=2', + method: 'POST', + headers: { + customHeader: 1 + }, + params: {}, + query: { + aa: '1', + bb: '2' + }, + data: {}, + responseHeaders: {}, + response: { + id: 1 + } + }); + + await expect(alovaInst.Post('/detail2').send()).rejects.toThrow('response error'); + expect(mockFn).toHaveBeenCalledTimes(1); // mockRequestLogger will not be called when throw error in `onMockResponse` }); test('response with status and statusText', async () => { @@ -162,7 +172,51 @@ describe('mock request', () => { expect(err.name).toBe('403'); expect(err.message).toBe('customer error'); } - expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + test('should return null when return `null` in mock', async () => { + const mocks = defineMock({ + '[POST]/detail': () => null + }); + + // 模拟数据请求适配器 + const mockRequestAdapter = createAlovaMockAdapter([mocks], { + delay: 10, + onMockResponse({ body }) { + return { + response: body, + headers: {} + }; + } + }); + const alova = createAlova({ + baseURL: 'http://xxx', + requestAdapter: mockRequestAdapter + }); + const data = await alova.Post('/detail'); + expect(data).toBeNull(); + }); + + test('should throw error of 404 when return `undefined` in mock', async () => { + const mocks = defineMock({ + '[POST]/detail': () => undefined + }); + + // 模拟数据请求适配器 + const mockRequestAdapter = createAlovaMockAdapter([mocks], { + delay: 10 + }); + const alova = createAlova({ + baseURL: 'http://xxx', + requestAdapter: mockRequestAdapter + }); + const response = await alova.Post('/detail'); + expect(response.status).toBe(404); + expect(response.statusText).toBe('api not found'); + + // access api that not exists + expect(alova.Post('/detail234')).rejects.toThrow('cannot find the httpAdapter'); }); test('should receive error when throw it in mock function', async () => { diff --git a/packages/adapter-mock/typings/index.d.ts b/packages/adapter-mock/typings/index.d.ts index 2d7d0ddd..f24338dc 100644 --- a/packages/adapter-mock/typings/index.d.ts +++ b/packages/adapter-mock/typings/index.d.ts @@ -84,7 +84,7 @@ export interface StatusResponse { body?: any; } export type MockFunction = (request: MockServerRequest) => StatusResponse | any; -export type Mock = Record | any[]>; +export type Mock = Record | any[]>; export interface MockWrapper { enable: boolean; diff --git a/packages/alova/typings/index.d.ts b/packages/alova/typings/index.d.ts index f2ad8f0f..0cb0e301 100644 --- a/packages/alova/typings/index.d.ts +++ b/packages/alova/typings/index.d.ts @@ -227,6 +227,10 @@ export interface ReferingObject { * the map of tracked state keys */ trackedKeys: Record; + /** + * has been bound error event + */ + bindError: boolean; [key: string]: any; } export interface StatesHook { diff --git a/packages/client/src/hooks/core/implements/createRequestState.ts b/packages/client/src/hooks/core/implements/createRequestState.ts index 533ef136..f90cfa87 100644 --- a/packages/client/src/hooks/core/implements/createRequestState.ts +++ b/packages/client/src/hooks/core/implements/createRequestState.ts @@ -18,7 +18,6 @@ import { forEach, isArray, isSSR, - len, promiseCatch, trueValue, undefinedValue @@ -67,7 +66,7 @@ export default function createRequestState { promiseCatch(handleRequest(), error => { - // the existence of error handlers and the error tracking indicates that the error need to throw. - if (len(eventManager.eventMap[KEY_ERROR] || []) <= 0 && !referingObject.trackedKeys.error) { + // the error tracking indicates that the error need to throw. + if (!referingObject.bindError && !referingObject.trackedKeys.error) { throw error; } }); @@ -182,6 +181,9 @@ export default function createRequestState) { + // will not throw error when bindError is true. + // it will reset in `exposeProvider` so that ignore the error binding in custom use hooks. + referingObject.bindError = trueValue; eventManager.on(KEY_ERROR, handler); }, onComplete(handler: CompleteHandler) { diff --git a/packages/client/src/hooks/silent/globalVariables.ts b/packages/client/src/hooks/silent/globalVariables.ts index da609ea8..fb66b953 100644 --- a/packages/client/src/hooks/silent/globalVariables.ts +++ b/packages/client/src/hooks/silent/globalVariables.ts @@ -125,4 +125,4 @@ export type GlobalSQEvents = { export const globalSQEventManager = createEventManager(); /** silentAssert */ -export const silentAssert = createAssert('useSQHook'); +export const silentAssert = createAssert('useSQRequest'); diff --git a/packages/client/src/hooks/silent/useSQRequest.ts b/packages/client/src/hooks/silent/useSQRequest.ts index 62fb4afe..27d7e571 100644 --- a/packages/client/src/hooks/silent/useSQRequest.ts +++ b/packages/client/src/hooks/silent/useSQRequest.ts @@ -1,6 +1,5 @@ import useRequest from '@/hooks/core/useRequest'; import { noop, statesHookHelper } from '@alova/shared/function'; -import { promiseResolve, undefinedValue } from '@alova/shared/vars'; import { AlovaGenerics, promiseStatesHook } from 'alova'; import { AlovaMethodHandler, SQRequestHookConfig, UseHookExposure } from '~/typings/clienthook'; import createSilentQueueMiddlewares from './createSilentQueueMiddlewares'; @@ -21,8 +20,9 @@ export default function useSQRequest( ...config, __referingObj: referingObj, middleware: (ctx, next) => { - middleware(ctx, () => promiseResolve(undefinedValue as any)); - return silentMiddleware(ctx, next); + const silentMidPromise = silentMiddleware(ctx, next); + middleware(ctx, () => silentMidPromise); + return silentMidPromise; } }); decorateEvent(states as UseHookExposure); diff --git a/packages/client/test/vue/useSQRequest.spec.ts b/packages/client/test/vue/useSQRequest.spec.ts index a6cf555a..fbdc514e 100644 --- a/packages/client/test/vue/useSQRequest.spec.ts +++ b/packages/client/test/vue/useSQRequest.spec.ts @@ -533,13 +533,13 @@ describe('vue => useSQRequest', () => { id: '--' }) }); - onPostSuccess(event => { + onPostSuccess(async event => { const { data } = event; expect(postRes.value[symbolVDataId]).toBeTruthy(); // 此时还是虚拟响应数据 // 调用updateStateEffect后将首先立即更新虚拟数据到listData中 // 等到请求响应后再次更新实际数据到listData中 - const updated = updateStateEffect(getter(), listDataRaw => { + const updated = await updateStateEffect(getter(), listDataRaw => { listDataRaw.push({ id: data.id, text: 'abc' diff --git a/packages/shared/src/function.ts b/packages/shared/src/function.ts index a73c2ce3..9380fd4d 100644 --- a/packages/shared/src/function.ts +++ b/packages/shared/src/function.ts @@ -385,7 +385,7 @@ type CompletedExposingProvider( statesHook: StatesHook, - referingObject: ReferingObject = { trackedKeys: {} } + referingObject: ReferingObject = { trackedKeys: {}, bindError: falseValue } ) { const ref = (initialValue: Data) => (statesHook.ref ? statesHook.ref(initialValue) : { current: initialValue }); referingObject = ref(referingObject).current; @@ -491,8 +491,10 @@ export function statesHookHelper( } const { update: nestedHookUpdate, __proxyState: nestedProxyState } = provider; - // reset the tracked keys, so that the nest hook providers can be initialized. + // reset the tracked keys and bingError flag, so that the nest hook providers can be initialized. referingObject.trackedKeys = {}; + referingObject.bindError = falseValue; + const extraProvider = { // expose referingObject automatically. __referingObj: referingObject,