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

Add a defaultContext property on ApolloClient #11275

Merged
merged 4 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
38 changes: 38 additions & 0 deletions .changeset/breezy-spiders-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@apollo/client": patch
---

Add a `defaultContext` option and property on `ApolloClient`, e.g. for keeping track of changing auth tokens or dependency injection.

This can be used e.g. in authentication scenarios, where a new token might be
generated outside of the link chain and should passed into the link chain.

```js
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
uri: '/graphql',
});

const authLink = setContext((_, { headers, token }) => {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
});

const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});

// somewhere else in your application
function onNewToken(newToken) {
// token can now be changed for future requests without need for a global
// variable, scoped ref or recreating the client
client.defaultContext.token = newToken
}
```
7 changes: 7 additions & 0 deletions src/core/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type ApolloClientOptions<TCacheShape> = {
connectToDevTools?: boolean;
queryDeduplication?: boolean;
defaultOptions?: DefaultOptions;
defaultContext?: Partial<DefaultContext>;
assumeImmutableResults?: boolean;
resolvers?: Resolvers | Resolvers[];
typeDefs?: string | string[] | DocumentNode | DocumentNode[];
Expand Down Expand Up @@ -150,6 +151,7 @@ export class ApolloClient<TCacheShape> implements DataProxy {
__DEV__,
queryDeduplication = true,
defaultOptions,
defaultContext,
assumeImmutableResults = cache.assumeImmutableResults,
resolvers,
typeDefs,
Expand Down Expand Up @@ -199,6 +201,7 @@ export class ApolloClient<TCacheShape> implements DataProxy {
cache: this.cache,
link: this.link,
defaultOptions: this.defaultOptions,
defaultContext,
documentTransform,
queryDeduplication,
ssrMode,
Expand Down Expand Up @@ -692,4 +695,8 @@ export class ApolloClient<TCacheShape> implements DataProxy {
public setLink(newLink: ApolloLink) {
this.link = this.queryManager.link = newLink;
}

public get defaultContext() {
return this.queryManager.defaultContext;
}
}
10 changes: 9 additions & 1 deletion src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { equal } from "@wry/equality";
import type { ApolloLink, FetchResult } from "../link/core/index.js";
import { execute } from "../link/core/index.js";
import {
compact,
hasDirectives,
isExecutionPatchIncrementalResult,
isExecutionPatchResult,
Expand Down Expand Up @@ -62,6 +63,7 @@ import type {
InternalRefetchQueriesOptions,
InternalRefetchQueriesResult,
InternalRefetchQueriesMap,
DefaultContext,
} from "./types.js";
import { LocalState } from "./LocalState.js";

Expand Down Expand Up @@ -106,6 +108,7 @@ export class QueryManager<TStore> {
public readonly assumeImmutableResults: boolean;
public readonly documentTransform: DocumentTransform;
public readonly ssrMode: boolean;
public readonly defaultContext: Partial<DefaultContext>;

private queryDeduplication: boolean;
private clientAwareness: Record<string, string> = {};
Expand Down Expand Up @@ -137,6 +140,7 @@ export class QueryManager<TStore> {
clientAwareness = {},
localState,
assumeImmutableResults = !!cache.assumeImmutableResults,
defaultContext,
}: {
cache: ApolloCache<TStore>;
link: ApolloLink;
Expand All @@ -148,6 +152,7 @@ export class QueryManager<TStore> {
clientAwareness?: Record<string, string>;
localState?: LocalState<TStore>;
assumeImmutableResults?: boolean;
defaultContext?: Partial<DefaultContext>;
}) {
const defaultDocumentTransform = new DocumentTransform(
(document) => this.cache.transformDocument(document),
Expand All @@ -172,6 +177,7 @@ export class QueryManager<TStore> {
// selections and fragments from the fragment registry.
.concat(defaultDocumentTransform)
: defaultDocumentTransform;
this.defaultContext = defaultContext || Object.create(null);

if ((this.onBroadcast = onBroadcast)) {
this.mutationStore = Object.create(null);
Expand Down Expand Up @@ -1154,7 +1160,9 @@ export class QueryManager<TStore> {
return asyncMap(
this.getObservableFromLink(
linkDocument,
options.context,
// explicitly a shallow merge so any class instances etc. a user might
// put in here will not be merged into each other.
compact(this.defaultContext, options.context),
options.variables
),

Expand Down
217 changes: 217 additions & 0 deletions src/core/__tests__/QueryManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6253,4 +6253,221 @@ describe("QueryManager", () => {
}
);
});

describe("defaultContext", () => {
let _: any; // trash variable to throw away values when destructuring
_ = _; // omit "'_' is declared but its value is never read." compiler warning

it("ApolloClient and QueryManager share a `defaultContext` instance (default empty object)", () => {
const client = new ApolloClient({
cache: new InMemoryCache(),
link: ApolloLink.empty(),
});

expect(client.defaultContext).toBe(client["queryManager"].defaultContext);
});

it("ApolloClient and QueryManager share a `defaultContext` instance (provided option)", () => {
const defaultContext = {};
const client = new ApolloClient({
cache: new InMemoryCache(),
link: ApolloLink.empty(),
defaultContext,
});

expect(client.defaultContext).toBe(defaultContext);
expect(client["queryManager"].defaultContext).toBe(defaultContext);
});

it("`defaultContext` cannot be reassigned on the user-facing `ApolloClient`", () => {
const client = new ApolloClient({
cache: new InMemoryCache(),
link: ApolloLink.empty(),
});

expect(() => {
// @ts-ignore
client.defaultContext = { query: { fetchPolicy: "cache-only" } };
}).toThrowError(/Cannot set property defaultContext/);
});

it("`defaultContext` will be applied to the context of a query", async () => {
let context: any;
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new ApolloLink(
(operation) =>
new Observable((observer) => {
({ cache: _, ...context } = operation.getContext());
observer.complete();
})
),
defaultContext: {
foo: "bar",
},
});

await client.query({
query: gql`
query {
foo
}
`,
});

expect(context.foo).toBe("bar");
});

it("`ApolloClient.defaultContext` can be modified and changes will show up in future queries", async () => {
let context: any;
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new ApolloLink(
(operation) =>
new Observable((observer) => {
({ cache: _, ...context } = operation.getContext());
observer.complete();
})
),
defaultContext: {
foo: "bar",
},
});

// one query to "warm up" with an old value to make sure the value
// isn't locked in at the first query or something
await client.query({
query: gql`
query {
foo
}
`,
});

expect(context.foo).toBe("bar");

client.defaultContext.foo = "changed";

await client.query({
query: gql`
query {
foo
}
`,
});

expect(context.foo).toBe("changed");
});

it("`defaultContext` will be shallowly merged with explicit context", async () => {
let context: any;
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new ApolloLink(
(operation) =>
new Observable((observer) => {
({ cache: _, ...context } = operation.getContext());
observer.complete();
})
),
defaultContext: {
foo: { bar: "baz" },
a: { b: "c" },
},
});

await client.query({
query: gql`
query {
foo
}
`,
context: {
a: { x: "y" },
},
});

expect(context).toEqual(
expect.objectContaining({
foo: { bar: "baz" },
a: { b: undefined, x: "y" },
Copy link
Member

Choose a reason for hiding this comment

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

Is the b: undefined here actually correct? Since its a shallow merge, I'd expect a to just be { x: 'y' } with the b key completely omitted. Would it be useful to make sure this test checks that? I think it passes right now because technically the value of b is undefined in both cases, but it feels misleading to show it here since this makes it look like it does some kind of deep merge.

Copy link
Member Author

@phryneas phryneas Oct 23, 2023

Choose a reason for hiding this comment

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

This is just my way of convincing jest to make sure that b isn't there or has no value.
If I tested for a: { x: "y" }, jest would also accept a a: { b: "still here", x: "y" }, as we are using objectContaining (the real context has a bunch of other values I don't care about and that I don't want to specify in this test)

})
);
});

it("`defaultContext` will be shallowly merged with context from `defaultOptions.query.context", async () => {
Copy link
Member

Choose a reason for hiding this comment

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

Actually this test brings up a good question:

What is the value of a defaultContext on its own rather than using defaultOptions.<query|watchQuery>.context? I'd like to better understand why someone couldn't just use defaultOptions.

Copy link
Member Author

@phryneas phryneas Oct 23, 2023

Choose a reason for hiding this comment

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

Just for completeness (we talked about this in person) - the defaultOptions would overwrite context instead of shallow-merging it, and also require people to save their token to three different places instead of having it in one central position (so a big DX improvement).

let context: any;
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new ApolloLink(
(operation) =>
new Observable((observer) => {
({ cache: _, ...context } = operation.getContext());
observer.complete();
})
),
defaultContext: {
foo: { bar: "baz" },
a: { b: "c" },
},
defaultOptions: {
query: {
context: {
a: { x: "y" },
},
},
},
});

await client.query({
query: gql`
query {
foo
}
`,
});

expect(context.foo).toStrictEqual({ bar: "baz" });
expect(context.a).toStrictEqual({ x: "y" });
});

it(
"document existing behavior: `defaultOptions.query.context` will be " +
"completely overwritten by, not merged with, explicit context",
async () => {
let context: any;
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new ApolloLink(
(operation) =>
new Observable((observer) => {
({ cache: _, ...context } = operation.getContext());
observer.complete();
})
),
defaultOptions: {
query: {
context: {
foo: { bar: "baz" },
},
},
},
});

await client.query({
query: gql`
query {
foo
}
`,
context: {
a: { x: "y" },
},
});

expect(context.a).toStrictEqual({ x: "y" });
expect(context.foo).toBeUndefined();
}
);
});
});