From df9b471f19a2b536997333760c8ffae9bc2d3eab Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 12 Dec 2025 18:41:08 +0800 Subject: [PATCH 1/4] fix(package/react): Prevent bloated fetches in React ~19.2 dev mode --- .changeset/early-yaks-visit.md | 5 + .changeset/open-cooks-thank.md | 5 + packages/gqty/src/Accessor/resolve.ts | 254 +++++++++++++--------- packages/gqty/src/Client/context.ts | 13 ++ packages/gqty/src/Helpers/getFields.ts | 21 +- packages/gqty/src/Helpers/selectFields.ts | 109 ++++++---- packages/react/src/query/useQuery.ts | 78 ++++--- 7 files changed, 308 insertions(+), 177 deletions(-) create mode 100644 .changeset/early-yaks-visit.md create mode 100644 .changeset/open-cooks-thank.md diff --git a/.changeset/early-yaks-visit.md b/.changeset/early-yaks-visit.md new file mode 100644 index 000000000..a668a19e3 --- /dev/null +++ b/.changeset/early-yaks-visit.md @@ -0,0 +1,5 @@ +--- +'gqty': minor +--- + +Block selections from object spreads diff --git a/.changeset/open-cooks-thank.md b/.changeset/open-cooks-thank.md new file mode 100644 index 000000000..85c3c3fec --- /dev/null +++ b/.changeset/open-cooks-thank.md @@ -0,0 +1,5 @@ +--- +'@gqty/react': minor +--- + +Prevent bloated fetches in React ~19.2 dev mode diff --git a/packages/gqty/src/Accessor/resolve.ts b/packages/gqty/src/Accessor/resolve.ts index 614aff2b5..90ace3d5d 100644 --- a/packages/gqty/src/Accessor/resolve.ts +++ b/packages/gqty/src/Accessor/resolve.ts @@ -209,120 +209,172 @@ export const createUnionAccessor = ({ }; /** - * Globally defining the proxy handler to avoid accidential scope references. + * Creates a proxy handler for object accessors. + * + * Key fix for React 19 dev mode: For data-backed proxies, `ownKeys()` only + * returns keys that exist in the cache, not all schema fields. This prevents + * React's prop diffing from triggering selections for fields the user never + * requested. The `allowEnumeration` context flag overrides this behavior for + * helpers like selectFields() that need to enumerate all schema fields. */ -const objectProxyHandler: ProxyHandler = { - get(currentType: Record, key, proxy) { - if (typeof key !== 'string') return; - - if (key === 'toJSON') { - return () => { - const data = $meta(proxy)?.cache.data; - - if (typeof data !== 'object' || data === null) { - return data; - } +const createObjectProxyHandler = ( + data: CacheObject | undefined, + context: { activeEnumerators: number } +): ProxyHandler => { + return { + ownKeys(target) { + // When allowEnumeration > 0 (e.g., selectFields helper), always + // return all schema keys regardless of cache state. + if (context.activeEnumerators > 0) { + return Reflect.ownKeys(target).filter( + (k) => typeof k === 'string' + ) as string[]; + } - return Object.entries(data).reduce>( - (prev, [key, value]) => { - if (!isSkeleton(value)) { - prev[key] = value; - } + // For data-backed proxies with actual data, only return keys that exist + // in the cache. This prevents React 19's prop diffing from seeing all + // schema fields when it enumerates a proxy - it only sees the actually + // cached fields. + // + // For skeleton proxies (no data or empty data), return all schema keys + // so that spread and for...in work correctly for initial selections. + const dataKeys = data + ? (Reflect.ownKeys(data).filter( + (k) => typeof k === 'string' + ) as string[]) + : []; + + if (dataKeys.length > 0) { + return dataKeys; + } - return prev; - }, - {} + return Reflect.ownKeys(target).filter( + (k) => typeof k === 'string' + ) as string[]; + }, + getOwnPropertyDescriptor(target, key) { + // For data-backed proxies, check data first + if (data) { + return ( + Reflect.getOwnPropertyDescriptor(data, key) ?? + Reflect.getOwnPropertyDescriptor(target, key) ); - }; - } + } - const meta = $meta(proxy); - if (!meta) return; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(currentType: Record, key, proxy) { + if (typeof key !== 'string') return; + + if (key === 'toJSON') { + return () => { + const data = $meta(proxy)?.cache.data; + + if (typeof data !== 'object' || data === null) { + return data; + } + + return Object.entries(data).reduce>( + (prev, [key, value]) => { + if (!isSkeleton(value)) { + prev[key] = value; + } + + return prev; + }, + {} + ); + }; + } - if ( - // Skip Query, Mutation and Subscription - meta.selection.parent !== undefined && - // Prevent infinite recursions - !getIdentityFields(meta).includes(key) - ) { - selectIdentityFields(proxy, currentType); - } + const meta = $meta(proxy); + if (!meta) return; - const targetType = currentType[key]; - if (!targetType || typeof targetType !== 'object') return; - - const { __args, __type } = targetType; - if (__args) { - return (args?: Record) => - resolve( - proxy, - meta.selection.getChild( - key, - args ? { input: { types: __args!, values: args } } : {} - ), - __type - ); - } + if ( + // Skip Query, Mutation and Subscription + meta.selection.parent !== undefined && + // Prevent infinite recursions + !getIdentityFields(meta).includes(key) + ) { + selectIdentityFields(proxy, currentType); + } - return resolve(proxy, meta.selection.getChild(key), __type); - }, - set(_, key, value, proxy) { - const meta = $meta(proxy); - if (typeof key !== 'string' || !meta) return false; + const targetType = currentType[key]; + if (!targetType || typeof targetType !== 'object') return; + + const { __args, __type } = targetType; + if (__args) { + return (args?: Record) => + resolve( + proxy, + meta.selection.getChild( + key, + args ? { input: { types: __args!, values: args } } : {} + ), + __type + ); + } - const { cache, context, selection } = meta; + return resolve(proxy, meta.selection.getChild(key), __type); + }, + set(_, key, value, proxy) { + const meta = $meta(proxy); + if (typeof key !== 'string' || !meta) return false; - // Extract proxy data, keep the object reference unless users deep clone it. - value = deepMetadata(value) ?? value; + const { cache, context, selection } = meta; - if (selection.ancestry.length <= 2) { - const [type, field] = selection.cacheKeys; + // Extract proxy data, keep the object reference unless users deep clone it. + value = deepMetadata(value) ?? value; - if (field) { - const data = - context.cache.get(`${type}.${field}`, context.cacheOptions)?.data ?? - {}; + if (selection.ancestry.length <= 2) { + const [type, field] = selection.cacheKeys; - if (!isPlainObject(data)) return false; + if (field) { + const data = + context.cache.get(`${type}.${field}`, context.cacheOptions)?.data ?? + {}; - data[key] = value; + if (!isPlainObject(data)) return false; - context.cache.set({ [type]: { [field]: data } }); - } else { - context.cache.set({ [type]: { [key]: value } }); - } - } + data[key] = value; - let result = false; + context.cache.set({ [type]: { [field]: data } }); + } else { + context.cache.set({ [type]: { [key]: value } }); + } + } - if (isCacheObject(cache.data)) { - result = Reflect.set(cache.data, key, value); - } + let result = false; - /** - * Ported for backward compatability. - * - * Triggering selections via optimistic updates is asking for infinite - * recursions, also it's unnecessarily complicated to infer arrays, - * interfaces and union selections down the selection tree. - * - * If we can't figure out an elegant way to infer selections in future - * iterations, remove it at some point. - */ - for (const [keys, scalar] of flattenObject(value)) { - let currentSelection = selection.getChild(key); - for (const key of keys) { - // Skip array indices - if (!isNaN(Number(key))) continue; - - currentSelection = currentSelection.getChild(key); + if (isCacheObject(cache.data)) { + result = Reflect.set(cache.data, key, value); } - context.select(currentSelection, { ...cache, data: scalar }); - } + /** + * Ported for backward compatability. + * + * Triggering selections via optimistic updates is asking for infinite + * recursions, also it's unnecessarily complicated to infer arrays, + * interfaces and union selections down the selection tree. + * + * If we can't figure out an elegant way to infer selections in future + * iterations, remove it at some point. + */ + for (const [keys, scalar] of flattenObject(value)) { + let currentSelection = selection.getChild(key); + for (const key of keys) { + // Skip array indices + if (!isNaN(Number(key))) continue; + + currentSelection = currentSelection.getChild(key); + } - return result; - }, + context.select(currentSelection, { ...cache, data: scalar }); + } + + return result; + }, + }; }; export type AccessorOptions = { @@ -334,6 +386,7 @@ export const createObjectAccessor = ( ) => { const { cache: { data }, + context, context: { schema }, type: { __type }, } = meta; @@ -349,16 +402,17 @@ export const createObjectAccessor = ( const type = schema[parseSchemaType(__type).pureType]; if (!type) throw new GQtyError(`Invalid schema type ${__type}.`); + // Create a per-proxy handler + // Pass context for allowEnumeration flag access + const handler = createObjectProxyHandler( + isCacheObject(data) ? data : undefined, + context + ); + const proxy = new Proxy( // `type` here for ownKeys proxy trap type as TSchemaType, - data - ? Object.assign({}, objectProxyHandler, { - getOwnPropertyDescriptor: (target, key) => - Reflect.getOwnPropertyDescriptor(data, key) ?? - Reflect.getOwnPropertyDescriptor(target, key), - } satisfies typeof objectProxyHandler) - : objectProxyHandler + handler ); $meta.set(proxy, meta); diff --git a/packages/gqty/src/Client/context.ts b/packages/gqty/src/Client/context.ts index adbd6eb11..000e01c08 100644 --- a/packages/gqty/src/Client/context.ts +++ b/packages/gqty/src/Client/context.ts @@ -27,6 +27,16 @@ export type SchemaContext< * `shouldFetch` is true and `hasCacheHit` is false. */ hasCacheMiss: boolean; + /** + * When > 0, enumeration-based access (spread, for...in, Object.keys, etc.) + * will enumerate all schema fields. When 0 (default), only cached fields + * are returned during enumeration. This prevents React 19's prop diffing + * from selecting all fields while allowing helpers like selectFields() to + * work correctly. + * + * Using a counter instead of boolean allows re-entrant/nested helper calls. + */ + activeEnumerators: number; }; export type CreateContextOptions = { @@ -52,6 +62,7 @@ export const createContext = ({ const selectSubscriptions = new Set(); return { + activeEnumerators: 0, aliasLength, cache: cachePolicy === 'no-cache' || @@ -98,6 +109,8 @@ export const createContext = ({ this.shouldFetch = false; this.hasCacheHit = false; this.hasCacheMiss = false; + this.shouldFetch = false; + this.activeEnumerators = 0; this.notifyCacheUpdate = cachePolicy !== 'default'; }, subscribeSelect(callback) { diff --git a/packages/gqty/src/Helpers/getFields.ts b/packages/gqty/src/Helpers/getFields.ts index 824b79fd8..5fc0f0922 100644 --- a/packages/gqty/src/Helpers/getFields.ts +++ b/packages/gqty/src/Helpers/getFields.ts @@ -1,13 +1,26 @@ +import { $meta } from '../Accessor'; import { isObject, isPlainObject } from '../Utils'; export function getFields< TAccesorData extends object | undefined | null, - TAccesorKeys extends keyof NonNullable + TAccesorKeys extends keyof NonNullable, >(accessor: TAccesorData, ...keys: TAccesorKeys[]): TAccesorData { if (!isObject(accessor)) return accessor; - if (keys.length) for (const key of keys) Reflect.get(accessor, key); - else for (const key in accessor) Reflect.get(accessor, key); + // Allow enumeration to see all schema fields, not just cached ones + const meta = $meta(accessor); + if (meta) { + meta.context.activeEnumerators++; + } + + try { + if (keys.length) for (const key of keys) Reflect.get(accessor, key); + else for (const key in accessor) Reflect.get(accessor, key); + } finally { + if (meta) { + meta.context.activeEnumerators--; + } + } return accessor; } @@ -15,7 +28,7 @@ export function getFields< export function getArrayFields< TArrayValue extends object | null | undefined, TArray extends TArrayValue[] | null | undefined, - TArrayValueKeys extends keyof NonNullable[number]> + TArrayValueKeys extends keyof NonNullable[number]>, >(accessorArray: TArray, ...keys: TArrayValueKeys[]): TArray { if (accessorArray == null) return accessorArray; diff --git a/packages/gqty/src/Helpers/selectFields.ts b/packages/gqty/src/Helpers/selectFields.ts index 73dbc1fd9..abe4f6ff8 100644 --- a/packages/gqty/src/Helpers/selectFields.ts +++ b/packages/gqty/src/Helpers/selectFields.ts @@ -1,5 +1,6 @@ import get from 'just-safe-get'; import set from 'just-safe-set'; +import { $meta } from '../Accessor'; import { isObject } from '../Utils'; export function selectFields( @@ -23,59 +24,71 @@ export function selectFields( return {} as A; } - if (typeof fields === 'string') { - if (recursionDepth > 0) { - const allAccessorKeys = Object.keys(accessor); - return allAccessorKeys.reduce((acum, fieldName) => { - const fieldValue: unknown = get(accessor, fieldName); - - if (Array.isArray(fieldValue)) { - set( - acum, - fieldName, - fieldValue.map((value) => { - return selectFields(value, '*', recursionDepth - 1); - }) - ); - } else if (isObject(fieldValue)) { - set( - acum, - fieldName, - selectFields(fieldValue, '*', recursionDepth - 1) - ); - } else { - set(acum, fieldName, fieldValue); - } - return acum; - }, {} as NonNullable); - } else { - return null as A; - } + // Allow enumeration to see all schema fields, not just cached ones + const meta = $meta(accessor); + if (meta) { + meta.context.activeEnumerators++; } - return fields.reduce((acum, fieldName) => { - if (typeof fieldName === 'number') { - fieldName = fieldName.toString(); + try { + if (typeof fields === 'string') { + if (recursionDepth > 0) { + const allAccessorKeys = Object.keys(accessor); + return allAccessorKeys.reduce((acum, fieldName) => { + const fieldValue: unknown = get(accessor, fieldName); + + if (Array.isArray(fieldValue)) { + set( + acum, + fieldName, + fieldValue.map((value) => { + return selectFields(value, '*', recursionDepth - 1); + }) + ); + } else if (isObject(fieldValue)) { + set( + acum, + fieldName, + selectFields(fieldValue, '*', recursionDepth - 1) + ); + } else { + set(acum, fieldName, fieldValue); + } + return acum; + }, {} as NonNullable); + } else { + return null as A; + } } - const fieldValue = get(accessor, fieldName); + return fields.reduce((acum, fieldName) => { + if (typeof fieldName === 'number') { + fieldName = fieldName.toString(); + } - if (fieldValue === undefined) return acum; + const fieldValue = get(accessor, fieldName); - if (Array.isArray(fieldValue)) { - set( - acum, - fieldName, - fieldValue.map((value) => { - return selectFields(value, '*', recursionDepth); - }) - ); - } else if (isObject(fieldValue)) { - set(acum, fieldName, selectFields(fieldValue, '*', recursionDepth)); - } else { - set(acum, fieldName, fieldValue); - } + if (fieldValue === undefined) return acum; - return acum; - }, {} as NonNullable); + if (Array.isArray(fieldValue)) { + set( + acum, + fieldName, + fieldValue.map((value) => { + return selectFields(value, '*', recursionDepth); + }) + ); + } else if (isObject(fieldValue)) { + set(acum, fieldName, selectFields(fieldValue, '*', recursionDepth)); + } else { + set(acum, fieldName, fieldValue); + } + + return acum; + }, {} as NonNullable); + } finally { + if (meta) { + meta.context.activeEnumerators--; + } + } } diff --git a/packages/react/src/query/useQuery.ts b/packages/react/src/query/useQuery.ts index a72a8f2f2..5f44b4618 100644 --- a/packages/react/src/query/useQuery.ts +++ b/packages/react/src/query/useQuery.ts @@ -26,9 +26,7 @@ import { useWindowFocusEffect } from '../useWindowFocusEffect'; import type { ReactClientOptionsWithDefaults } from '../utils'; export interface UseQueryPrepareHelpers< - GeneratedSchema extends { - query: object; - }, + GeneratedSchema extends { query: object }, > { readonly prepass: typeof prepass; readonly query: GeneratedSchema['query']; @@ -529,29 +527,59 @@ export const createUseQuery = ( }, [refetch, swrDiff]); return useMemo(() => { - return new Proxy( - Object.freeze({ - $refetch: (ignoreCache = true) => - refetch({ ignoreCache, skipOnError: false }), - $state: Object.freeze({ - isLoading: state.promise !== undefined || initialStateRef.current, - error: state.error, - }), + // Minimal target with only our custom properties. + // We avoid pre-populating schema keys which is O(schemaSize) per hook. + const target = Object.freeze({ + $refetch: (ignoreCache = true) => + refetch({ ignoreCache, skipOnError: false }), + $state: Object.freeze({ + isLoading: state.promise !== undefined || initialStateRef.current, + error: state.error, }), - { - get: (target, key, proxy) => - Reflect.get(target, key, proxy) ?? - Reflect.get( - prepare && cachePolicy !== 'no-store' - ? // Using global schema accessor prevents the second pass fetch - // essentially let `prepare` decides what data to fetch, data - // placeholder will always render in case of a cache miss. - client.schema.query - : query, - key - ), - } - ); + }); + + // The underlying query accessor to delegate field access to + const queryAccessor = + prepare && cachePolicy !== 'no-store' + ? // Using global schema accessor prevents the second pass fetch + // essentially let `prepare` decides what data to fetch, data + // placeholder will always render in case of a cache miss. + client.schema.query + : query; + + return new Proxy(target, { + // Only expose $refetch and $state in enumeration. + // This prevents React 19 dev mode from enumerating all schema fields + // during prop diffing, which would trigger unintended selections. + // Users who need to enumerate query fields should access the underlying + // accessor directly or use selectFields/getFields helpers. + ownKeys: () => ['$refetch', '$state'], + + getOwnPropertyDescriptor: (target, key) => { + if (key === '$refetch' || key === '$state') { + return Reflect.getOwnPropertyDescriptor(target, key); + } + // Return undefined for schema keys - they're not "own" properties + return undefined; + }, + + has: (_, key) => { + // $refetch and $state are always present + if (key === '$refetch' || key === '$state') return true; + // Schema keys are accessible via get but not enumerable + return key in queryAccessor; + }, + + get: (target, key, proxy) => { + // Return our custom properties + if (key in target) { + return Reflect.get(target, key, proxy); + } + + // Delegate all other access to the query accessor + return Reflect.get(queryAccessor, key); + }, + }); }, [query, refetch, state.error, state.promise]); }; }; From 3c863dd80d7c4f1f247cb7e64355b8c9a4749100 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 12 Dec 2025 19:26:47 +0800 Subject: [PATCH 2/4] fix: copilot reviews --- packages/gqty/src/Accessor/resolve.ts | 6 +++--- packages/gqty/src/Client/context.ts | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/gqty/src/Accessor/resolve.ts b/packages/gqty/src/Accessor/resolve.ts index 90ace3d5d..2ec58df9a 100644 --- a/packages/gqty/src/Accessor/resolve.ts +++ b/packages/gqty/src/Accessor/resolve.ts @@ -214,7 +214,7 @@ export const createUnionAccessor = ({ * Key fix for React 19 dev mode: For data-backed proxies, `ownKeys()` only * returns keys that exist in the cache, not all schema fields. This prevents * React's prop diffing from triggering selections for fields the user never - * requested. The `allowEnumeration` context flag overrides this behavior for + * requested. The `activeEnumerators` context flag overrides this behavior for * helpers like selectFields() that need to enumerate all schema fields. */ const createObjectProxyHandler = ( @@ -223,7 +223,7 @@ const createObjectProxyHandler = ( ): ProxyHandler => { return { ownKeys(target) { - // When allowEnumeration > 0 (e.g., selectFields helper), always + // When activeEnumerators > 0 (e.g., selectFields helper), always // return all schema keys regardless of cache state. if (context.activeEnumerators > 0) { return Reflect.ownKeys(target).filter( @@ -403,7 +403,7 @@ export const createObjectAccessor = ( if (!type) throw new GQtyError(`Invalid schema type ${__type}.`); // Create a per-proxy handler - // Pass context for allowEnumeration flag access + // Pass context for activeEnumerators flag access const handler = createObjectProxyHandler( isCacheObject(data) ? data : undefined, context diff --git a/packages/gqty/src/Client/context.ts b/packages/gqty/src/Client/context.ts index 000e01c08..69fa9327b 100644 --- a/packages/gqty/src/Client/context.ts +++ b/packages/gqty/src/Client/context.ts @@ -106,12 +106,11 @@ export const createContext = ({ selectSubscriptions.forEach((fn) => fn(selection, cacheNode)); }, reset() { - this.shouldFetch = false; + this.activeEnumerators = 0; this.hasCacheHit = false; this.hasCacheMiss = false; - this.shouldFetch = false; - this.activeEnumerators = 0; this.notifyCacheUpdate = cachePolicy !== 'default'; + this.shouldFetch = false; }, subscribeSelect(callback) { selectSubscriptions.add(callback); From 60635706a86f664a588a86ce0a6b600be19fc2b7 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 12 Dec 2025 19:46:53 +0800 Subject: [PATCH 3/4] fix: add production guard --- packages/gqty/src/Accessor/resolve.ts | 5 +++- packages/gqty/src/Helpers/getFields.ts | 7 ++++-- packages/gqty/src/Helpers/selectFields.ts | 7 ++++-- packages/react/src/query/useQuery.ts | 29 +++++++++++++---------- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/gqty/src/Accessor/resolve.ts b/packages/gqty/src/Accessor/resolve.ts index 2ec58df9a..9276e361f 100644 --- a/packages/gqty/src/Accessor/resolve.ts +++ b/packages/gqty/src/Accessor/resolve.ts @@ -225,7 +225,10 @@ const createObjectProxyHandler = ( ownKeys(target) { // When activeEnumerators > 0 (e.g., selectFields helper), always // return all schema keys regardless of cache state. - if (context.activeEnumerators > 0) { + if ( + process.env.NODE_ENV === 'production' || + context.activeEnumerators > 0 + ) { return Reflect.ownKeys(target).filter( (k) => typeof k === 'string' ) as string[]; diff --git a/packages/gqty/src/Helpers/getFields.ts b/packages/gqty/src/Helpers/getFields.ts index 5fc0f0922..a8126f359 100644 --- a/packages/gqty/src/Helpers/getFields.ts +++ b/packages/gqty/src/Helpers/getFields.ts @@ -7,8 +7,11 @@ export function getFields< >(accessor: TAccesorData, ...keys: TAccesorKeys[]): TAccesorData { if (!isObject(accessor)) return accessor; - // Allow enumeration to see all schema fields, not just cached ones - const meta = $meta(accessor); + // Allow enumeration to see all schema fields, not just cached ones. + // Only needed in dev mode where we restrict ownKeys to prevent React 19's + // prop diffing from triggering selections. + const meta = + process.env.NODE_ENV !== 'production' ? $meta(accessor) : undefined; if (meta) { meta.context.activeEnumerators++; } diff --git a/packages/gqty/src/Helpers/selectFields.ts b/packages/gqty/src/Helpers/selectFields.ts index abe4f6ff8..c9305138d 100644 --- a/packages/gqty/src/Helpers/selectFields.ts +++ b/packages/gqty/src/Helpers/selectFields.ts @@ -24,8 +24,11 @@ export function selectFields( return {} as A; } - // Allow enumeration to see all schema fields, not just cached ones - const meta = $meta(accessor); + // Allow enumeration to see all schema fields, not just cached ones. + // Only needed in dev mode where we restrict ownKeys to prevent React 19's + // prop diffing from triggering selections. + const meta = + process.env.NODE_ENV !== 'production' ? $meta(accessor) : undefined; if (meta) { meta.context.activeEnumerators++; } diff --git a/packages/react/src/query/useQuery.ts b/packages/react/src/query/useQuery.ts index 5f44b4618..b6dbb4292 100644 --- a/packages/react/src/query/useQuery.ts +++ b/packages/react/src/query/useQuery.ts @@ -548,20 +548,25 @@ export const createUseQuery = ( : query; return new Proxy(target, { - // Only expose $refetch and $state in enumeration. + // Only expose $refetch and $state in enumeration in dev mode. // This prevents React 19 dev mode from enumerating all schema fields // during prop diffing, which would trigger unintended selections. - // Users who need to enumerate query fields should access the underlying - // accessor directly or use selectFields/getFields helpers. - ownKeys: () => ['$refetch', '$state'], - - getOwnPropertyDescriptor: (target, key) => { - if (key === '$refetch' || key === '$state') { - return Reflect.getOwnPropertyDescriptor(target, key); - } - // Return undefined for schema keys - they're not "own" properties - return undefined; - }, + // In production, return actual target keys (just $refetch and $state). + ownKeys: + process.env.NODE_ENV !== 'production' + ? () => ['$refetch', '$state'] + : undefined, + + getOwnPropertyDescriptor: + process.env.NODE_ENV !== 'production' + ? (target, key) => { + if (key === '$refetch' || key === '$state') { + return Reflect.getOwnPropertyDescriptor(target, key); + } + // Return undefined for schema keys - they're not "own" properties + return undefined; + } + : undefined, has: (_, key) => { // $refetch and $state are always present From 0328043df3bb682049f92aff7d44011011add364 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 12 Dec 2025 19:53:04 +0800 Subject: [PATCH 4/4] chore(ci): combine workflow files for NPM Trusted Publisher --- .github/workflows/canary.yaml | 19 --------- .github/workflows/release.yaml | 76 ++++++++++++++++++++++++++++++++-- .github/workflows/tests.yaml | 2 +- 3 files changed, 74 insertions(+), 23 deletions(-) delete mode 100644 .github/workflows/canary.yaml diff --git a/.github/workflows/canary.yaml b/.github/workflows/canary.yaml deleted file mode 100644 index 4c01f2164..000000000 --- a/.github/workflows/canary.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Canary Release - -on: - pull_request: - branches: - - main - -jobs: - release-canary: - uses: the-guild-org/shared-config/.github/workflows/release-snapshot.yml@main - if: - ${{ github.actor != 'dependabot[bot]' && github.actor != - 'dependabot-preview[bot]' && github.actor != 'renovate[bot]' }} - with: - packageManager: pnpm - npmTag: canary - secrets: - githubToken: ${{ secrets.GITHUB_TOKEN }} - npmToken: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 629fe3c18..0b2d15024 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,6 +1,9 @@ name: Release on: + pull_request: + branches: + - main push: paths-ignore: - 'docs/**' @@ -11,9 +14,75 @@ on: - main jobs: + release-canary: + name: Canary Release + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + if: >- + ${{ + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' && + github.actor != 'dependabot-preview[bot]' && + github.actor != 'renovate[bot]' + }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Ensure npm supports OIDC trusted publishing + run: npm install -g npm@latest + + - name: Setup pnpm store + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install Dependencies + run: pnpm i --frozen-lockfile + + - name: Create snapshot versions + run: pnpm exec changeset version --snapshot canary + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + run: pnpm run build + + - name: Publish canary + run: pnpm exec changeset publish --no-git-tag --tag canary + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + release: name: Release runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -29,17 +98,18 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: 'pnpm' - name: Setup pnpm store + id: pnpm-store run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - name: Setup pnpm cache uses: actions/cache@v4 with: - path: ${{ env.STORE_PATH }} + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 835ecdb6f..5433bcfea 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -27,7 +27,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [20, 22] + node-version: [20, 22, 24] os: [ubuntu-latest] steps: - name: Checkout