Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/early-yaks-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gqty': minor
---

Block selections from object spreads
5 changes: 5 additions & 0 deletions .changeset/open-cooks-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gqty/react': minor
---

Prevent bloated fetches in React ~19.2 dev mode
257 changes: 157 additions & 100 deletions packages/gqty/src/Accessor/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,120 +209,175 @@ 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 `activeEnumerators` context flag overrides this behavior for
* helpers like selectFields() that need to enumerate all schema fields.
*/
const objectProxyHandler: ProxyHandler<GeneratedSchemaObject> = {
get(currentType: Record<string, Type | undefined>, 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<GeneratedSchemaObject> => {
return {
ownKeys(target) {
// When activeEnumerators > 0 (e.g., selectFields helper), always
// return all schema keys regardless of cache state.
if (
process.env.NODE_ENV === 'production' ||
context.activeEnumerators > 0
) {
return Reflect.ownKeys(target).filter(
(k) => typeof k === 'string'
) as string[];
}

return Object.entries(data).reduce<Record<string, unknown>>(
(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);
},
Comment thread
vicary marked this conversation as resolved.
Comment thread
vicary marked this conversation as resolved.
get(currentType: Record<string, Type | undefined>, 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<Record<string, unknown>>(
(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<string, unknown>) =>
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<string, unknown>) =>
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 = {
Expand All @@ -334,6 +389,7 @@ export const createObjectAccessor = <TSchemaType extends GeneratedSchemaObject>(
) => {
const {
cache: { data },
context,
context: { schema },
type: { __type },
} = meta;
Expand All @@ -349,16 +405,17 @@ export const createObjectAccessor = <TSchemaType extends GeneratedSchemaObject>(
const type = schema[parseSchemaType(__type).pureType];
if (!type) throw new GQtyError(`Invalid schema type ${__type}.`);

// Create a per-proxy handler
// Pass context for activeEnumerators 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);
Expand Down
14 changes: 13 additions & 1 deletion packages/gqty/src/Client/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -52,6 +62,7 @@ export const createContext = ({
const selectSubscriptions = new Set<Selectable['select']>();

return {
activeEnumerators: 0,
aliasLength,
cache:
cachePolicy === 'no-cache' ||
Expand Down Expand Up @@ -95,10 +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.notifyCacheUpdate = cachePolicy !== 'default';
this.shouldFetch = false;
},
subscribeSelect(callback) {
selectSubscriptions.add(callback);
Expand Down
24 changes: 20 additions & 4 deletions packages/gqty/src/Helpers/getFields.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import { $meta } from '../Accessor';
import { isObject, isPlainObject } from '../Utils';

export function getFields<
TAccesorData extends object | undefined | null,
TAccesorKeys extends keyof NonNullable<TAccesorData>
TAccesorKeys extends keyof NonNullable<TAccesorData>,
>(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.
// 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++;
}

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;
}

export function getArrayFields<
TArrayValue extends object | null | undefined,
TArray extends TArrayValue[] | null | undefined,
TArrayValueKeys extends keyof NonNullable<NonNullable<TArray>[number]>
TArrayValueKeys extends keyof NonNullable<NonNullable<TArray>[number]>,
>(accessorArray: TArray, ...keys: TArrayValueKeys[]): TArray {
if (accessorArray == null) return accessorArray;

Expand Down
Loading
Loading