Skip to content

Keep a rebuilt async list's own type when the item type changed - #554

Merged
lukemurray merged 1 commit into
EntityGraphQL:mainfrom
Ko-Lin:fix/async-list-item-rebuild-type
Sep 8, 2026
Merged

lukemurray merged 1 commit into
EntityGraphQL:mainfrom
Ko-Lin:fix/async-list-item-rebuild-type

Conversation

@Ko-Lin

@Ko-Lin Ko-Lin commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

An async service field selected below an async service list field throws while the result is being assembled. Every read has already succeeded — it fails on the way back out.

Field 'people' - Object of type 'System.Collections.Generic.List`1[System.Object]' cannot be converted
to type 'System.Collections.Generic.IEnumerable`1[Dynamic_tags_240d757f-1543-422d-8afc-dbb25cc89f32]'.
   at System.Reflection.RtFieldInfo.SetValue(...)
   at EntityGraphQL.Compiler.ExecutableGraphQLStatement.RebuildDynamicTypeWithResolvedFields(...)
   at EntityGraphQL.Compiler.ExecutableGraphQLStatement.ResolveComplexObject(...)
   at EntityGraphQL.Compiler.ExecutableGraphQLStatement.ResolveAsyncResultsRecursive(...)

The added test is the minimum shape that reproduces it — Person.tags resolves a List<Tag> through ResolveAsync, and Tag.label has a ResolveAsync of its own:

{ people { id tags { name label } } }

Cause

tags is declared Task<IEnumerable<Dynamic_tags_X>> on the person's projection. Each Dynamic_tags_X holds an async member, so ResolveComplexObject rebuilds every item into a new dynamic type — the finished list no longer holds Dynamic_tags_X, and CanMaterializeTypedCollection correctly declines to make a typed list, falling back to List<object>.

That fallback is right. The member type the rebuilt parent is given for it is not: GetResolvedFieldType unwrapped Task<T> to T unconditionally, so the parent still declared the member IEnumerable<Dynamic_tags_X> and setting the List<object> on it threw.

The non-async path at the bottom of the same method already falls through to resolvedValue?.GetType(), which is why the same async child under a plain (non-Task) list field works today — its member type is taken from the rebuilt list rather than from a promise made before the rebuild.

Fix

Only keep T when the resolved value still is one:

if (taskGenericType != null && (resolvedValue == null || taskGenericType.IsInstanceOfType(resolvedValue)))
    return taskGenericType;

ValueTask<T> had the same hole and takes the same guard. The IAsyncEnumerable<T> branch looked like it needed one too, but is deliberately left alone: an IAsyncEnumerable field with a sub-selection fails earlier with No generic method 'SelectWithNull', so it cannot carry a projected, rebuildable item type. Worth revisiting together if that selection limitation is ever lifted.

This also sits next to the nullable-member fix from 6.2.2 a few lines below — that one exists because the resolved value's runtime type is untrustworthy for nullability, this one because the declared type is untrustworthy for shape. Each now defers to the other where it knows better.

Tests

AsyncShapesTests.AsyncListOfProjectedItems_WithAnAsyncFieldOnTheItem_StaysAssignableToTheField — fails on main with the error above, passes with the fix.

Full suite green: 1202 passed / 1 skipped, plus 47 EF tests.

Bumped to 6.2.4 with a CHANGELOG entry, following #553 — happy to drop either if you would rather cut the release yourself.

@Ko-Lin
Ko-Lin force-pushed the fix/async-list-item-rebuild-type branch from 4fa24bf to dcfd907 Compare September 8, 2026 12:18
An async service field selected below an async service list field crashed with
"Object of type 'List`1[System.Object]' cannot be converted to type
'IEnumerable`1[Dynamic_tags_...]'" - a query like { people { tags { name label } } }
where tags resolves a list via ResolveAsync and label on the item type resolves
via ResolveAsync too.

Resolving an item of the outer list rebuilds it, because the item projection
holds an async member of its own, so the finished list no longer holds the item
type the outer field was declared with and correctly falls back to List<object>.
GetResolvedFieldType unwrapped Task<T> to T unconditionally, so the rebuilt
parent still declared that member IEnumerable<T> and setting the resolved list
on it threw.

Only keep T when the resolved value still is one, otherwise take the resolved
value's own type - which is what the non-async path in the same method already
does. ValueTask<T> had the same hole and takes the same guard.

Bump to 6.2.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ko-Lin
Ko-Lin force-pushed the fix/async-list-item-rebuild-type branch from dcfd907 to b41301d Compare September 8, 2026 12:21
@lukemurray
lukemurray merged commit 991f180 into EntityGraphQL:main Sep 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants