Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed useQuery under StrictMode creating 2 observable query #11925

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/plenty-wolves-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fixed issue under StrictMode causing queries created with useQuery to be potentially refetched after unmounting.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.organizeImports": "never"
},
"cSpell.enableFiletypes": ["mdx"],
"jest.jestCommandLine": "node_modules/.bin/jest --config ./config/jest.config.js --ignoreProjects 'ReactDOM 17' --runInBand"
}
22 changes: 21 additions & 1 deletion src/core/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,17 @@ export class ApolloClient<TCacheShape> implements DataProxy {
T = any,
TVariables extends OperationVariables = OperationVariables,
>(options: WatchQueryOptions<TVariables, T>): ObservableQuery<T, TVariables> {
const { observable, register } = this.createQuery(options);
register();
return observable;
}

public createQuery<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd make this a private method. We treat public methods as part of the public API which means we can't make breaking changes to this after this is released and I'd prefer we keep this out of users hands if we can. By making it private, that should allow us to tweak it/rename it/etc. without that fear.

Since you use this from useQuery, TypeScript will obviously complain when you make this change, so to get around that, use bracket notation:

// useQuery.ts
client['createQuery'](options);

T = any,
TVariables extends OperationVariables = OperationVariables,
>(
options: WatchQueryOptions<TVariables, T>
): { observable: ObservableQuery<T, TVariables>; register: () => void } {
if (this.defaultOptions.watchQuery) {
options = mergeOptions(this.defaultOptions.watchQuery, options);
}
Expand All @@ -428,7 +439,16 @@ export class ApolloClient<TCacheShape> implements DataProxy {
options = { ...options, fetchPolicy: "cache-first" };
}

return this.queryManager.watchQuery<T, TVariables>(options);
const { observable, queryInfo } = this.queryManager.createQuery<
T,
TVariables
>(options);
return {
observable,
register: () => {
this.queryManager.registerObservableQuery(observable, queryInfo);
},
};
Comment on lines +446 to +451
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the suggested change to registeerObservableQuery, I'd actually suggest that we don't need to return this register function here and can just return the observable. We can then use queryManager.reigsterObservableQuery directly in place of this instead.

Suggested change
return {
observable,
register: () => {
this.queryManager.registerObservableQuery(observable, queryInfo);
},
};
return observable;

}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/core/QueryInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,17 @@ export class QueryInfo {
}

private shouldNotify() {
if (!this.dirty || !this.listeners.size) {
if (
!this.dirty ||
!this.listeners.size ||
// It's possible that the query is no longer being watched, but the
// ObservableQuery is still active/pending cleanup. In this case, we should not notify.
!this.observableQuery?.hasObservers()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably want to call this change out in the changelog in case we have any users that use the core watchQuery API directly. I'd say something along the lines of:

Cache writes to ObservableQuery with no observers will no longer receive cache updates or trigger refetches as a result of cache changes. This was usually caused when using useQuery with React's strict mode which could cause the creation of multiple ObservableQuery instances on mount. This change should only affect you if you create ObservableQuery instances via watchQuery and do not subscribe to them but expect cache updates and refetches to occur.

Obviously feel free to workshop this and make it your own, really as long as we have something here is what I care about.

Feel free to add this as a separate changeset since I'd view it as a separate change from the strict mode issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After talking with the team, I think we might want to move this change to 4.0 and make it a breaking change so that we can call it out prominently. Its difficult to tell who might rely on this behavior if you're using the core API directly. I think the fix for strict mode might be enough here to address what you're seeing specifically in your app. Moving this to a major allows us to ensure we add this behavior everywhere as well.

As an FYI, we will begin work on 4.0 once 3.12 ships (a few weeks from now), so shouldn't be long before we can address this 🙂

) {
return false;
}

if (isNetworkRequestInFlight(this.networkStatus) && this.observableQuery) {
if (isNetworkRequestInFlight(this.networkStatus)) {
const { fetchPolicy } = this.observableQuery.options;
if (fetchPolicy !== "cache-only" && fetchPolicy !== "cache-and-network") {
return false;
Expand Down
31 changes: 27 additions & 4 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,10 +720,16 @@ export class QueryManager<TStore> {
};
}

public watchQuery<
/**
* Create a query, but do not associate it with the QueryManager.
* This allows to throw away the query if it ends up not being needed
*/
Comment on lines +723 to +726
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fine if you want to keep this comment, but just an FYI we consider QueryManager a private class so it will only ever be used internally. I'm not opposed to more documentation, but this won't be seen by consumers of this library 🙂

public createQuery<
T,
TVariables extends OperationVariables = OperationVariables,
>(options: WatchQueryOptions<TVariables, T>): ObservableQuery<T, TVariables> {
>(
options: WatchQueryOptions<TVariables, T>
): { observable: ObservableQuery<T, TVariables>; queryInfo: QueryInfo } {
const query = this.transform(options.query);

// assign variable default values if supplied
Expand All @@ -746,8 +752,6 @@ export class QueryManager<TStore> {
});
observable["lastQuery"] = query;

this.queries.set(observable.queryId, queryInfo);

// We give queryInfo the transformed query to ensure the first cache diff
// uses the transformed query instead of the raw query
queryInfo.init({
Expand All @@ -756,6 +760,25 @@ export class QueryManager<TStore> {
variables: observable.variables,
});

return { observable, queryInfo };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the change suggested for registerObservableQuery, we shouldn't need to return queryInfo here since its set as a property on observable. I'd shorten this to just return the observable directly which should simplify some things.

Suggested change
return { observable, queryInfo };
return observable;

}

/**
* Register an ObservableQuery with the QueryManager created previously with createQuery.
*/
public registerObservableQuery(
observable: ObservableQuery<any, any>,
queryInfo: QueryInfo
): void {
this.queries.set(observable.queryId, queryInfo);
}
Comment on lines +769 to +774
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public registerObservableQuery(
observable: ObservableQuery<any, any>,
queryInfo: QueryInfo
): void {
this.queries.set(observable.queryId, queryInfo);
}
public registerObservableQuery(
observable: ObservableQuery<any, any>
): void {
this.queries.set(observable.queryId, observable['queryInfo']);
}

When we instantiate ObservableQuery, it gets queryInfo in its constructor and sets it as a private property. I'd say we can shorten this to just take the observable and access that queryInfo from there using the bracket notation which lets us get access to private properties.


public watchQuery<
T,
TVariables extends OperationVariables = OperationVariables,
>(options: WatchQueryOptions<TVariables, T>): ObservableQuery<T, TVariables> {
const { observable, queryInfo } = this.createQuery(options);
this.registerObservableQuery(observable, queryInfo);
return observable;
}

Expand Down
45 changes: 45 additions & 0 deletions src/core/__tests__/QueryManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4712,6 +4712,51 @@ describe("QueryManager", () => {
.then(resolve, reject);
}
);

itAsync(
"will not update inactive query on `resetStore` nor on `refetchQueries`",
(resolve, reject) => {
const testQuery = gql`
query TestQuery {
author {
firstName
lastName
}
}
`;
const link = new (class extends ApolloLink {
public request() {
reject(new Error("Query was not supposed to be called"));
return null;
}
})();

const queryManager = new QueryManager(
getDefaultOptionsForQueryManagerTests({
link,
cache: new InMemoryCache({ addTypename: false }),
})
);
const oq = queryManager.watchQuery({
query: testQuery,
fetchPolicy: "cache-and-network",
});
// Recreate state where an observable query is dirty but has no observers in the query manager
// @ts-expect-error -- Accessing private field for testing
oq.queryInfo.dirty = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, now that we've sorta pinned this down on both strict mode and the creation of multiple ObservableQuery instances without observers, do we have a need for this condition anymore?


resetStore(queryManager).then((q) => {
expect(q).toHaveLength(0);
expect(oq.hasObservers()).toBe(false);
resolve();
});

const refetched = queryManager.refetchQueries({
include: ["TestQuery"],
});
expect(refetched.size).toBe(0);
}
);
});

describe("loading state", () => {
Expand Down
70 changes: 68 additions & 2 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { DocumentNode, GraphQLError, GraphQLFormattedError } from "graphql";
import gql from "graphql-tag";
import { act } from "@testing-library/react";
import { act, configure } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor, renderHook } from "@testing-library/react";
import {
Expand Down Expand Up @@ -1542,7 +1542,7 @@ describe("useQuery Hook", () => {

function checkObservableQueries(expectedLinkCount: number) {
const obsQueries = client.getObservableQueries("all");
expect(obsQueries.size).toBe(2);
expect(obsQueries.size).toBe(1);

const activeSet = new Set<typeof result.current.observable>();
const inactiveSet = new Set<typeof result.current.observable>();
Expand Down Expand Up @@ -1597,6 +1597,72 @@ describe("useQuery Hook", () => {

checkObservableQueries(2);
});

it("only refetch active queries", async () => {
const query1 = gql`
{
user {
id
name
count
}
}
`;
const query2 = gql`
{
user {
id
name
}
}
`;
let count = 0;
let replyData = (): object => ({
data: { user: { id: "1", name: "Alice", count: ++count } },
});
const cache = new InMemoryCache();
const client = new ApolloClient({
link: new ApolloLink(() => Observable.of(replyData())),
cache,
});

function Query({ query }: { query: DocumentNode }) {
const { data } = useQuery(query, { fetchPolicy: "cache-and-network" });
return data?.user ?
<div>
{data.user.name}: {data.user.count}
</div>
: null;
}

function Component({ query }: { query: DocumentNode }) {
return (
<React.StrictMode>
<ApolloProvider client={client}>
<Query query={query} />
</ApolloProvider>
</React.StrictMode>
);
}

const { unmount } = render(<Component query={query1} />);
await screen.findAllByText("Alice: 1");
expect(client.getObservableQueries("all").size).toBe(1);
unmount();

await new Promise((resolve) => setTimeout(resolve));
expect(client.getObservableQueries("all").size).toBe(0);

replyData = () => ({ data: { user: { id: "1", name: "Alice" } } });
render(<Component query={query2} />, {});

expect(client.getObservableQueries("all").size).toBe(1);
await act(async () => {
const refetched = await client.reFetchObservableQueries();
expect(refetched).toHaveLength(1);
expect(refetched[0].data).toEqual({ user: { id: "1", name: "Alice" } });
});
});
});

describe("polling", () => {
Expand Down
37 changes: 28 additions & 9 deletions src/react/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ interface InternalState<TData, TVariables extends OperationVariables> {
client: ReturnType<typeof useApolloClient>;
query: DocumentNode | TypedDocumentNode<TData, TVariables>;
observable: ObsQueryWithMeta<TData, TVariables>;
registerObservableQuery: () => void;
resultData: InternalResult<TData, TVariables>;
}

Expand Down Expand Up @@ -179,18 +180,29 @@ function useInternalState<
function createInternalState(previous?: InternalState<TData, TVariables>) {
verifyDocumentType(query, DocumentType.Query);

// See if there is an existing observable that was used to fetch the same
// data and if so, use it instead since it will contain the proper queryId
// to fetch the result set. This is used during SSR.
const ssrObservable = renderPromises?.getSSRObservable(
makeWatchQueryOptions()
);
let { observable, register } =
ssrObservable ?
{ observable: ssrObservable, register: () => {} }
: client.createQuery(
getObsQueryOptions(void 0, client, options, makeWatchQueryOptions())
);
if (renderPromises && !ssrObservable) {
// In SSR, useEffects are not called, so we need to register the observable now
register();
register = () => {};
}

const internalState: InternalState<TData, TVariables> = {
client,
query,
observable:
// See if there is an existing observable that was used to fetch the same
// data and if so, use it instead since it will contain the proper queryId
// to fetch the result set. This is used during SSR.
(renderPromises &&
renderPromises.getSSRObservable(makeWatchQueryOptions())) ||
client.watchQuery(
getObsQueryOptions(void 0, client, options, makeWatchQueryOptions())
),
observable,
registerObservableQuery: register,
resultData: {
// Reuse previousData from previous InternalState (if any) to provide
// continuity of previousData even if/when the query or client changes.
Expand All @@ -204,6 +216,13 @@ function useInternalState<
let [internalState, updateInternalState] =
React.useState(createInternalState);

// Creating a watcher/listener in a `useState` breaks the rules of React
// useState initializers should be pure, aka, the result should be throwable and recreatable
// Instead of registering the observable with the QueryManager in the `useState` initializer, we create it in a `useEffect` hook
// The actual subscription of the query is handled in the `useObservableSubscriptionResult` hook
const registerObservableQuery = internalState.registerObservableQuery;
React.useEffect(registerObservableQuery, [registerObservableQuery]);

/**
* Used by `useLazyQuery` when a new query is executed.
* We keep this logic here since it needs to update things in unsafe
Expand Down