Skip to content

Commit df9b471

Browse files
committed
fix(package/react): Prevent bloated fetches in React ~19.2 dev mode
1 parent 524ca81 commit df9b471

7 files changed

Lines changed: 308 additions & 177 deletions

File tree

.changeset/early-yaks-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gqty': minor
3+
---
4+
5+
Block selections from object spreads

.changeset/open-cooks-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gqty/react': minor
3+
---
4+
5+
Prevent bloated fetches in React ~19.2 dev mode

packages/gqty/src/Accessor/resolve.ts

Lines changed: 154 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -209,120 +209,172 @@ export const createUnionAccessor = ({
209209
};
210210

211211
/**
212-
* Globally defining the proxy handler to avoid accidential scope references.
212+
* Creates a proxy handler for object accessors.
213+
*
214+
* Key fix for React 19 dev mode: For data-backed proxies, `ownKeys()` only
215+
* returns keys that exist in the cache, not all schema fields. This prevents
216+
* React's prop diffing from triggering selections for fields the user never
217+
* requested. The `allowEnumeration` context flag overrides this behavior for
218+
* helpers like selectFields() that need to enumerate all schema fields.
213219
*/
214-
const objectProxyHandler: ProxyHandler<GeneratedSchemaObject> = {
215-
get(currentType: Record<string, Type | undefined>, key, proxy) {
216-
if (typeof key !== 'string') return;
217-
218-
if (key === 'toJSON') {
219-
return () => {
220-
const data = $meta(proxy)?.cache.data;
221-
222-
if (typeof data !== 'object' || data === null) {
223-
return data;
224-
}
220+
const createObjectProxyHandler = (
221+
data: CacheObject | undefined,
222+
context: { activeEnumerators: number }
223+
): ProxyHandler<GeneratedSchemaObject> => {
224+
return {
225+
ownKeys(target) {
226+
// When allowEnumeration > 0 (e.g., selectFields helper), always
227+
// return all schema keys regardless of cache state.
228+
if (context.activeEnumerators > 0) {
229+
return Reflect.ownKeys(target).filter(
230+
(k) => typeof k === 'string'
231+
) as string[];
232+
}
225233

226-
return Object.entries(data).reduce<Record<string, unknown>>(
227-
(prev, [key, value]) => {
228-
if (!isSkeleton(value)) {
229-
prev[key] = value;
230-
}
234+
// For data-backed proxies with actual data, only return keys that exist
235+
// in the cache. This prevents React 19's prop diffing from seeing all
236+
// schema fields when it enumerates a proxy - it only sees the actually
237+
// cached fields.
238+
//
239+
// For skeleton proxies (no data or empty data), return all schema keys
240+
// so that spread and for...in work correctly for initial selections.
241+
const dataKeys = data
242+
? (Reflect.ownKeys(data).filter(
243+
(k) => typeof k === 'string'
244+
) as string[])
245+
: [];
246+
247+
if (dataKeys.length > 0) {
248+
return dataKeys;
249+
}
231250

232-
return prev;
233-
},
234-
{}
251+
return Reflect.ownKeys(target).filter(
252+
(k) => typeof k === 'string'
253+
) as string[];
254+
},
255+
getOwnPropertyDescriptor(target, key) {
256+
// For data-backed proxies, check data first
257+
if (data) {
258+
return (
259+
Reflect.getOwnPropertyDescriptor(data, key) ??
260+
Reflect.getOwnPropertyDescriptor(target, key)
235261
);
236-
};
237-
}
262+
}
238263

239-
const meta = $meta(proxy);
240-
if (!meta) return;
264+
return Reflect.getOwnPropertyDescriptor(target, key);
265+
},
266+
get(currentType: Record<string, Type | undefined>, key, proxy) {
267+
if (typeof key !== 'string') return;
268+
269+
if (key === 'toJSON') {
270+
return () => {
271+
const data = $meta(proxy)?.cache.data;
272+
273+
if (typeof data !== 'object' || data === null) {
274+
return data;
275+
}
276+
277+
return Object.entries(data).reduce<Record<string, unknown>>(
278+
(prev, [key, value]) => {
279+
if (!isSkeleton(value)) {
280+
prev[key] = value;
281+
}
282+
283+
return prev;
284+
},
285+
{}
286+
);
287+
};
288+
}
241289

242-
if (
243-
// Skip Query, Mutation and Subscription
244-
meta.selection.parent !== undefined &&
245-
// Prevent infinite recursions
246-
!getIdentityFields(meta).includes(key)
247-
) {
248-
selectIdentityFields(proxy, currentType);
249-
}
290+
const meta = $meta(proxy);
291+
if (!meta) return;
250292

251-
const targetType = currentType[key];
252-
if (!targetType || typeof targetType !== 'object') return;
253-
254-
const { __args, __type } = targetType;
255-
if (__args) {
256-
return (args?: Record<string, unknown>) =>
257-
resolve(
258-
proxy,
259-
meta.selection.getChild(
260-
key,
261-
args ? { input: { types: __args!, values: args } } : {}
262-
),
263-
__type
264-
);
265-
}
293+
if (
294+
// Skip Query, Mutation and Subscription
295+
meta.selection.parent !== undefined &&
296+
// Prevent infinite recursions
297+
!getIdentityFields(meta).includes(key)
298+
) {
299+
selectIdentityFields(proxy, currentType);
300+
}
266301

267-
return resolve(proxy, meta.selection.getChild(key), __type);
268-
},
269-
set(_, key, value, proxy) {
270-
const meta = $meta(proxy);
271-
if (typeof key !== 'string' || !meta) return false;
302+
const targetType = currentType[key];
303+
if (!targetType || typeof targetType !== 'object') return;
304+
305+
const { __args, __type } = targetType;
306+
if (__args) {
307+
return (args?: Record<string, unknown>) =>
308+
resolve(
309+
proxy,
310+
meta.selection.getChild(
311+
key,
312+
args ? { input: { types: __args!, values: args } } : {}
313+
),
314+
__type
315+
);
316+
}
272317

273-
const { cache, context, selection } = meta;
318+
return resolve(proxy, meta.selection.getChild(key), __type);
319+
},
320+
set(_, key, value, proxy) {
321+
const meta = $meta(proxy);
322+
if (typeof key !== 'string' || !meta) return false;
274323

275-
// Extract proxy data, keep the object reference unless users deep clone it.
276-
value = deepMetadata(value) ?? value;
324+
const { cache, context, selection } = meta;
277325

278-
if (selection.ancestry.length <= 2) {
279-
const [type, field] = selection.cacheKeys;
326+
// Extract proxy data, keep the object reference unless users deep clone it.
327+
value = deepMetadata(value) ?? value;
280328

281-
if (field) {
282-
const data =
283-
context.cache.get(`${type}.${field}`, context.cacheOptions)?.data ??
284-
{};
329+
if (selection.ancestry.length <= 2) {
330+
const [type, field] = selection.cacheKeys;
285331

286-
if (!isPlainObject(data)) return false;
332+
if (field) {
333+
const data =
334+
context.cache.get(`${type}.${field}`, context.cacheOptions)?.data ??
335+
{};
287336

288-
data[key] = value;
337+
if (!isPlainObject(data)) return false;
289338

290-
context.cache.set({ [type]: { [field]: data } });
291-
} else {
292-
context.cache.set({ [type]: { [key]: value } });
293-
}
294-
}
339+
data[key] = value;
295340

296-
let result = false;
341+
context.cache.set({ [type]: { [field]: data } });
342+
} else {
343+
context.cache.set({ [type]: { [key]: value } });
344+
}
345+
}
297346

298-
if (isCacheObject(cache.data)) {
299-
result = Reflect.set(cache.data, key, value);
300-
}
347+
let result = false;
301348

302-
/**
303-
* Ported for backward compatability.
304-
*
305-
* Triggering selections via optimistic updates is asking for infinite
306-
* recursions, also it's unnecessarily complicated to infer arrays,
307-
* interfaces and union selections down the selection tree.
308-
*
309-
* If we can't figure out an elegant way to infer selections in future
310-
* iterations, remove it at some point.
311-
*/
312-
for (const [keys, scalar] of flattenObject(value)) {
313-
let currentSelection = selection.getChild(key);
314-
for (const key of keys) {
315-
// Skip array indices
316-
if (!isNaN(Number(key))) continue;
317-
318-
currentSelection = currentSelection.getChild(key);
349+
if (isCacheObject(cache.data)) {
350+
result = Reflect.set(cache.data, key, value);
319351
}
320352

321-
context.select(currentSelection, { ...cache, data: scalar });
322-
}
353+
/**
354+
* Ported for backward compatability.
355+
*
356+
* Triggering selections via optimistic updates is asking for infinite
357+
* recursions, also it's unnecessarily complicated to infer arrays,
358+
* interfaces and union selections down the selection tree.
359+
*
360+
* If we can't figure out an elegant way to infer selections in future
361+
* iterations, remove it at some point.
362+
*/
363+
for (const [keys, scalar] of flattenObject(value)) {
364+
let currentSelection = selection.getChild(key);
365+
for (const key of keys) {
366+
// Skip array indices
367+
if (!isNaN(Number(key))) continue;
368+
369+
currentSelection = currentSelection.getChild(key);
370+
}
323371

324-
return result;
325-
},
372+
context.select(currentSelection, { ...cache, data: scalar });
373+
}
374+
375+
return result;
376+
},
377+
};
326378
};
327379

328380
export type AccessorOptions = {
@@ -334,6 +386,7 @@ export const createObjectAccessor = <TSchemaType extends GeneratedSchemaObject>(
334386
) => {
335387
const {
336388
cache: { data },
389+
context,
337390
context: { schema },
338391
type: { __type },
339392
} = meta;
@@ -349,16 +402,17 @@ export const createObjectAccessor = <TSchemaType extends GeneratedSchemaObject>(
349402
const type = schema[parseSchemaType(__type).pureType];
350403
if (!type) throw new GQtyError(`Invalid schema type ${__type}.`);
351404

405+
// Create a per-proxy handler
406+
// Pass context for allowEnumeration flag access
407+
const handler = createObjectProxyHandler(
408+
isCacheObject(data) ? data : undefined,
409+
context
410+
);
411+
352412
const proxy = new Proxy(
353413
// `type` here for ownKeys proxy trap
354414
type as TSchemaType,
355-
data
356-
? Object.assign({}, objectProxyHandler, {
357-
getOwnPropertyDescriptor: (target, key) =>
358-
Reflect.getOwnPropertyDescriptor(data, key) ??
359-
Reflect.getOwnPropertyDescriptor(target, key),
360-
} satisfies typeof objectProxyHandler)
361-
: objectProxyHandler
415+
handler
362416
);
363417

364418
$meta.set(proxy, meta);

packages/gqty/src/Client/context.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export type SchemaContext<
2727
* `shouldFetch` is true and `hasCacheHit` is false.
2828
*/
2929
hasCacheMiss: boolean;
30+
/**
31+
* When > 0, enumeration-based access (spread, for...in, Object.keys, etc.)
32+
* will enumerate all schema fields. When 0 (default), only cached fields
33+
* are returned during enumeration. This prevents React 19's prop diffing
34+
* from selecting all fields while allowing helpers like selectFields() to
35+
* work correctly.
36+
*
37+
* Using a counter instead of boolean allows re-entrant/nested helper calls.
38+
*/
39+
activeEnumerators: number;
3040
};
3141

3242
export type CreateContextOptions = {
@@ -52,6 +62,7 @@ export const createContext = ({
5262
const selectSubscriptions = new Set<Selectable['select']>();
5363

5464
return {
65+
activeEnumerators: 0,
5566
aliasLength,
5667
cache:
5768
cachePolicy === 'no-cache' ||
@@ -98,6 +109,8 @@ export const createContext = ({
98109
this.shouldFetch = false;
99110
this.hasCacheHit = false;
100111
this.hasCacheMiss = false;
112+
this.shouldFetch = false;
113+
this.activeEnumerators = 0;
101114
this.notifyCacheUpdate = cachePolicy !== 'default';
102115
},
103116
subscribeSelect(callback) {

packages/gqty/src/Helpers/getFields.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
1+
import { $meta } from '../Accessor';
12
import { isObject, isPlainObject } from '../Utils';
23

34
export function getFields<
45
TAccesorData extends object | undefined | null,
5-
TAccesorKeys extends keyof NonNullable<TAccesorData>
6+
TAccesorKeys extends keyof NonNullable<TAccesorData>,
67
>(accessor: TAccesorData, ...keys: TAccesorKeys[]): TAccesorData {
78
if (!isObject(accessor)) return accessor;
89

9-
if (keys.length) for (const key of keys) Reflect.get(accessor, key);
10-
else for (const key in accessor) Reflect.get(accessor, key);
10+
// Allow enumeration to see all schema fields, not just cached ones
11+
const meta = $meta(accessor);
12+
if (meta) {
13+
meta.context.activeEnumerators++;
14+
}
15+
16+
try {
17+
if (keys.length) for (const key of keys) Reflect.get(accessor, key);
18+
else for (const key in accessor) Reflect.get(accessor, key);
19+
} finally {
20+
if (meta) {
21+
meta.context.activeEnumerators--;
22+
}
23+
}
1124

1225
return accessor;
1326
}
1427

1528
export function getArrayFields<
1629
TArrayValue extends object | null | undefined,
1730
TArray extends TArrayValue[] | null | undefined,
18-
TArrayValueKeys extends keyof NonNullable<NonNullable<TArray>[number]>
31+
TArrayValueKeys extends keyof NonNullable<NonNullable<TArray>[number]>,
1932
>(accessorArray: TArray, ...keys: TArrayValueKeys[]): TArray {
2033
if (accessorArray == null) return accessorArray;
2134

0 commit comments

Comments
 (0)