Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/tame-rocks-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fix `watchFragment` reports `complete=false` when from object is not identifiable
148 changes: 147 additions & 1 deletion src/__tests__/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { Kind } from "graphql";
import { DeepPartial, Observable } from "../utilities";
import { ApolloLink, FetchResult } from "../link/core";
import { HttpLink } from "../link/http";
import { createFragmentRegistry, InMemoryCache } from "../cache";
import {
createFragmentRegistry,
InMemoryCache,
MissingFieldError,
} from "../cache";
import { ObservableStream, spyOnConsole } from "../testing/internal";
import { TypedDocumentNode } from "@graphql-typed-document-node/core";
import { invariant } from "../utilities/globals";
Expand Down Expand Up @@ -2647,6 +2651,148 @@ describe("ApolloClient", () => {
});
}
});

it("reports complete as false when `from` is not identifiable", async () => {
const cache = new InMemoryCache();
const client = new ApolloClient({
cache,
link: ApolloLink.empty(),
});
const ItemFragment = gql`
fragment ItemFragment on Item {
id
text
}
`;

const observable = client.watchFragment({
fragment: ItemFragment,
from: {},
});

const stream = new ObservableStream(observable);

{
const result = await stream.takeNext();

expect(result).toStrictEqual({
data: {},
complete: false,
missing:
"Can't determine completeness for fragment query on non-identifiable object",
});
}
});

it("reports diffs correctly when using getFragmentDoc", async () => {
const cache = new InMemoryCache();

const diffWithFragment = cache.diff({
query: cache["getFragmentDoc"](gql`
fragment FooFragment on Foo {
foo
}
`),
id: cache.identify({}),
returnPartialData: true,
optimistic: true,
});

expect(diffWithFragment).toStrictEqual({
result: {},
complete: false,
missing: [
new MissingFieldError(
"Can't determine completeness for fragment query on non-identifiable object",
expect.anything(), // query
expect.anything() // variables
),
],
});

await new Promise((res) => {
cache.watch({
query: cache["getFragmentDoc"](gql`
fragment FooFragment on Foo {
foo
}
`),
id: cache.identify({}),
returnPartialData: true,
immediate: true,
optimistic: true,
callback: (diff) => {
expect(diff).toStrictEqual({
result: {},
complete: false,
missing: [
new MissingFieldError(
"Can't determine completeness for fragment query on non-identifiable object",
expect.anything(), // query
expect.anything() // variables
),
],
});
res(void 0);
},
});
});
});

it("reports diffs correctly when not using getFragmentDoc", async () => {
const cache = new InMemoryCache();

const diffWithoutFragment = cache.diff({
query: gql`
query {
foo
}
`,
id: cache.identify({}),
returnPartialData: true,
optimistic: true,
});

expect(diffWithoutFragment).toStrictEqual({
result: {},
complete: false,
missing: [
new MissingFieldError(
"Can't find field 'foo' on ROOT_QUERY object",
expect.anything(), // query
expect.anything() // variables
),
],
});

await new Promise((res) => {
cache.watch({
query: gql`
query {
foo
}
`,
id: cache.identify({}),
returnPartialData: true,
immediate: true,
optimistic: true,
callback: (diff) => {
expect(diff).toStrictEqual({
result: {},
complete: false,
missing: [
new MissingFieldError(
"Can't find field 'foo' on ROOT_QUERY object",
expect.anything(), // query
expect.anything() // variables
),
],
});
res(void 0);
},
});
});
});
});

describe("defaultOptions", () => {
Expand Down
6 changes: 2 additions & 4 deletions src/__tests__/dataMasking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,8 +1360,7 @@ describe("client.watchQuery", () => {
const { data, complete } = await fragmentStream.takeNext();

expect(data).toEqual({});
// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed
expect(complete).toBe(true);
expect(complete).toBe(false);
}
});

Expand Down Expand Up @@ -1437,8 +1436,7 @@ describe("client.watchQuery", () => {
const { data, complete } = await fragmentStream.takeNext();

expect(data).toEqual({});
// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed
expect(complete).toBe(true);
expect(complete).toBe(false);
}
});

Expand Down
30 changes: 30 additions & 0 deletions src/cache/inmemory/inMemoryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
print,
cacheSizes,
defaultCacheSizes,
getMainDefinition,
} from "../../utilities/index.js";
import type { InMemoryCacheConfig, NormalizedCacheObject } from "./types.js";
import { StoreReader } from "./readFromStore.js";
Expand Down Expand Up @@ -256,6 +257,35 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> {
public diff<TData, TVariables extends OperationVariables = any>(
options: Cache.DiffOptions<TData, TVariables>
): Cache.DiffResult<TData> {
// Detect non-identifiable objects with fragment queries
// Only apply this fix when:
// 1. options.id is explicitly undefined (not just missing)
// 2. The query is a pure fragment query (not a named query with fragments)
if (options.id === undefined && hasOwn.call(options, "id")) {
const mainDef = getMainDefinition(options.query);
// Check if this is a pure fragment query (operation without name, only fragment spreads)
if (
mainDef.kind === "OperationDefinition" &&
!mainDef.name &&
mainDef.selectionSet.selections.every(
(sel) => sel.kind === "FragmentSpread"
)
) {
return {
result: {} as TData,
complete: false,
missing: [
new MissingFieldError(
"Can't determine completeness for fragment query on non-identifiable object",
"Can't determine completeness for fragment query on non-identifiable object",
options.query,
options.variables
),
],
};
}
}

return this.storeReader.diffQueryAgainstStore({
...options,
store: options.optimistic ? this.optimisticData : this.data,
Expand Down
3 changes: 1 addition & 2 deletions src/react/hooks/__tests__/useFragment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1639,8 +1639,7 @@ describe("useFragment", () => {
const { data, complete } = await takeSnapshot();

expect(data).toEqual({});
// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed
expect(complete).toBe(true);
expect(complete).toBe(false);
}

expect(console.warn).toHaveBeenCalledTimes(1);
Expand Down
88 changes: 42 additions & 46 deletions src/react/hooks/__tests__/useSuspenseFragment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -870,64 +870,60 @@ it("does not rerender when fields with @nonreactive on nested fragment change",
await expect(takeSnapshot).not.toRerender();
});

// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed
it.failing(
"warns and suspends when passing parent object to `from` when key fields are missing",
async () => {
using _ = spyOnConsole("warn");

interface Fragment {
age: number;
it("warns and suspends when passing parent object to `from` when key fields are missing", async () => {
using _ = spyOnConsole("warn");

interface Fragment {
age: number;
}

const fragment: TypedDocumentNode<Fragment, never> = gql`
fragment UserFields on User {
age
}
`;

const fragment: TypedDocumentNode<Fragment, never> = gql`
fragment UserFields on User {
age
}
`;
const client = new ApolloClient({ cache: new InMemoryCache() });

const client = new ApolloClient({ cache: new InMemoryCache() });
const { replaceSnapshot, render, takeRender } =
createDefaultRenderStream<Fragment>();
const { SuspenseFallback } = createDefaultTrackedComponents();

const { replaceSnapshot, render, takeRender } =
createDefaultRenderStream<Fragment>();
const { SuspenseFallback } = createDefaultTrackedComponents();
function App() {
const result = useSuspenseFragment({
fragment,
from: { __typename: "User" },
});

function App() {
const result = useSuspenseFragment({
fragment,
from: { __typename: "User" },
});
replaceSnapshot({ result });

replaceSnapshot({ result });
return null;
}

return null;
using _disabledAct = disableActEnvironment();
await render(
<Suspense fallback={<SuspenseFallback />}>
<App />
</Suspense>,
{
wrapper: ({ children }) => (
<ApolloProvider client={client}>{children}</ApolloProvider>
),
}
);

using _disabledAct = disableActEnvironment();
await render(
<Suspense fallback={<SuspenseFallback />}>
<App />
</Suspense>,
{
wrapper: ({ children }) => (
<ApolloProvider client={client}>{children}</ApolloProvider>
),
}
);

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
"Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.",
"UserFields"
);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
"Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.",
"UserFields"
);

{
const { renderedComponents } = await takeRender();
{
const { renderedComponents } = await takeRender();

expect(renderedComponents).toStrictEqual([SuspenseFallback]);
}
expect(renderedComponents).toStrictEqual([SuspenseFallback]);
}
);
});

test("returns null if `from` is `null`", async () => {
interface ItemFragment {
Expand Down