diff --git a/.changeset/dry-foxes-train.md b/.changeset/dry-foxes-train.md new file mode 100644 index 000000000..13786c3a9 --- /dev/null +++ b/.changeset/dry-foxes-train.md @@ -0,0 +1,5 @@ +--- +'@envelop/response-cache': minor +--- + +Reduce overhead diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index 679c4eaeb..30800ee39 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -25,6 +25,7 @@ import { OnExecuteHookResult, Plugin, } from '@envelop/core'; +import { ExecutionResultWithSerializer } from '@envelop/graphql-jit'; import { getDirective, MapperKind, @@ -495,14 +496,9 @@ export function useResponseCache = {}> result: ExecutionResult, setResult: (newResult: ExecutionResult) => void, ): void { - let changed = false; if (result.data) { - result = { ...result }; - result.data = removeMetadataFieldsFromResult( - result.data as Record, - onEntity, - ); - changed = true; + collectEntities(result.data as Record, onEntity); + setStringifyWithoutMetadata(result); } const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); @@ -523,9 +519,6 @@ export function useResponseCache = {}> ); } } - if (changed) { - setResult(result); - } } if (invalidateViaMutation !== false) { @@ -591,7 +584,8 @@ export function useResponseCache = {}> setResult: (newResult: ExecutionResult) => void, ) { if (result.data) { - result.data = removeMetadataFieldsFromResult(result.data, onEntity); + collectEntities(result.data, onEntity); + setStringifyWithoutMetadata(result); } // we only use the global ttl if no currentTtl has been determined. @@ -656,7 +650,7 @@ function handleAsyncIterableResult = { onNext(payload) { // This is the first result with the initial data payload sent to the client. We use it as the base result if (payload.result.data) { - result.data = payload.result.data; + result.data = structuredClone(payload.result.data); } if (payload.result.errors) { result.errors = payload.result.errors; @@ -669,7 +663,10 @@ function handleAsyncIterableResult = { const { incremental, hasNext } = payload.result; if (incremental) { for (const patch of incremental) { - mergeIncrementalResult({ executionResult: result, incrementalResult: patch }); + mergeIncrementalResult({ + executionResult: result, + incrementalResult: structuredClone(patch), + }); } } @@ -679,32 +676,24 @@ function handleAsyncIterableResult = { } } - const newResult = { ...payload.result }; - - // Handle initial/single result - if (newResult.data) { - newResult.data = removeMetadataFieldsFromResult(newResult.data); + if (payload.result.data) { + collectEntities(payload.result.data); } // Handle Incremental results - if ('hasNext' in newResult && newResult.incremental) { - newResult.incremental = newResult.incremental.map(value => { + if ('hasNext' in payload.result && payload.result.incremental) { + payload.result.incremental = payload.result.incremental.map(value => { if ('items' in value && value.items) { - return { - ...value, - items: removeMetadataFieldsFromResult(value.items), - }; + collectEntities(value.items); } if ('data' in value && value.data) { - return { - ...value, - data: removeMetadataFieldsFromResult(value.data), - }; + collectEntities(value.data); } return value; }); } - payload.setResult(newResult); + + setStringifyWithoutMetadata(result); }, }; } @@ -764,64 +753,64 @@ type OnEntityHandler = ( data: Record, ) => void | Promise; -function removeMetadataFieldsFromResult( - data: Record, - onEntity?: OnEntityHandler, -): Record; -function removeMetadataFieldsFromResult( - data: Array>, - onEntity?: OnEntityHandler, -): Array>; -function removeMetadataFieldsFromResult( - data: null | undefined, - onEntity?: OnEntityHandler, -): null | undefined; -function removeMetadataFieldsFromResult( +function collectEntities(data: Record, onEntity?: OnEntityHandler): void; +function collectEntities(data: Array>, onEntity?: OnEntityHandler): void; +function collectEntities(data: null | undefined, onEntity?: OnEntityHandler): void; +function collectEntities( data: Record | Array> | null | undefined, onEntity?: OnEntityHandler, -): Record | Array | null | undefined { +): void { if (Array.isArray(data)) { - return data.map(record => removeMetadataFieldsFromResult(record, onEntity)); + for (const record of data) { + collectEntities(record, onEntity); + } + return; } if (typeof data !== 'object' || data == null) { - return data; + return; } const dataPrototype = Object.getPrototypeOf(data); - if (dataPrototype != null && dataPrototype !== Object.prototype) { - return data; + // TODO: For some reason, when running in Jest, `structuredClone` result have a weird prototype + // that doesn't equal Object.prototype as it should. + // As a workaround, we can just check that there is no parent prototype, which should be + // the case only when it's the Object prototype + // Rollback once migrated to Bun or vitest + // + // if (dataPrototype != null && dataPrototype !== Object.prototype) { + if (dataPrototype != null && Object.getPrototypeOf(dataPrototype) !== null) { + // It is not a plain object, like a Date, don't inspect further + return; } - // clone the data to avoid mutation - data = { ...data }; - const typename = data.__responseCacheTypeName ?? data.__typename; if (typeof typename === 'string') { const entity: CacheEntityRecord = { typename }; - delete data.__responseCacheTypeName; if ( data.__responseCacheId && (typeof data.__responseCacheId === 'string' || typeof data.__responseCacheId === 'number') ) { entity.id = data.__responseCacheId; - delete data.__responseCacheId; } onEntity?.(entity, data); } for (const key in data) { - const value = data[key]; - if (Array.isArray(value)) { - data[key] = removeMetadataFieldsFromResult(value, onEntity); - } - if (value !== null && typeof value === 'object') { - data[key] = removeMetadataFieldsFromResult(value as Record, onEntity); - } + collectEntities(data[key] as any, onEntity); } - - return data; } + +const setStringifyWithoutMetadata = (result: ExecutionResultWithSerializer) => { + result.stringify = stringifyWithoutMetadata; + return result; +}; + +const stringifyWithoutMetadata: ExecutionResultWithSerializer['stringify'] = result => { + return JSON.stringify(result, (key: string, value: unknown) => + key === '__responseCacheId' || key === '__responseCacheTypeName' ? undefined : value, + ); +};