diff --git a/change/@graphitation-apollo-forest-run-c0098f3e-15b9-4b8c-9f7e-cc97f866a63f.json b/change/@graphitation-apollo-forest-run-c0098f3e-15b9-4b8c-9f7e-cc97f866a63f.json new file mode 100644 index 000000000..517f0b8a8 --- /dev/null +++ b/change/@graphitation-apollo-forest-run-c0098f3e-15b9-4b8c-9f7e-cc97f866a63f.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "observer", + "packageName": "@graphitation/apollo-forest-run", + "email": "pavelglac@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/apollo-forest-run/compat/package-lock.json b/packages/apollo-forest-run/compat/package-lock.json index 0caa720a2..2aa163cb8 100644 --- a/packages/apollo-forest-run/compat/package-lock.json +++ b/packages/apollo-forest-run/compat/package-lock.json @@ -2318,6 +2318,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7201,6 +7216,13 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/packages/apollo-forest-run/src/ForestRun.ts b/packages/apollo-forest-run/src/ForestRun.ts index 9811acf07..41d0d9907 100644 --- a/packages/apollo-forest-run/src/ForestRun.ts +++ b/packages/apollo-forest-run/src/ForestRun.ts @@ -19,6 +19,7 @@ import type { Transaction, } from "./cache/types"; import { ApolloCache } from "@apollo/client"; +import { Observable } from "@apollo/client/utilities"; import { assert } from "./jsutils/assert"; import { accumulate, deleteAccumulated } from "./jsutils/map"; import { read } from "./cache/read"; @@ -342,11 +343,18 @@ export class ForestRun< public watch( watch: Cache.WatchOptions, ): () => void { - return this.env.optimizeFragmentReads && + if ( + this.env.optimizeFragmentReads && isFragmentDocument(watch.query) && (watch.id || watch.rootId) - ? this.watchFragment(watch) - : this.watchOperation(watch); + ) { + const id = watch.id ?? watch.rootId; + accumulate(this.store.fragmentWatches, id, watch); + return () => { + deleteAccumulated(this.store.fragmentWatches, id, watch); + }; + } + return this.watchOperation(watch); } private watchOperation(watch: Cache.WatchOptions) { @@ -370,14 +378,91 @@ export class ForestRun< }; } - private watchFragment(watch: Cache.WatchOptions) { - const id = watch.id ?? watch.rootId; - assert(id !== undefined); - accumulate(this.store.fragmentWatches, id, watch); - - return () => { - deleteAccumulated(this.store.fragmentWatches, id, watch); + public watchFragment(options: { + fragment: DocumentNode; + fragmentName?: string; + from: StoreObject | Reference | string; + optimistic?: boolean; + variables?: TVars; + }): Observable<{ + data: TData; + complete: boolean; + missing?: any; + }> { + const { + fragment, + fragmentName, + from, + optimistic = true, + ...otherOptions + } = options; + const query = this["getFragmentDoc"](fragment, fragmentName); + // While our TypeScript types do not allow for `undefined` as a valid + // `from`, its possible `useFragment` gives us an `undefined` since it + // calls `cache.identify` and provides that value to `from`. We are + // adding this fix here however to ensure those using plain JavaScript + // and using `cache.identify` themselves will avoid seeing the obscure + // warning. + const id = + typeof from === "undefined" || typeof from === "string" + ? from + : this.identify(from); + + const diffOptions: Cache.DiffOptions = { + ...otherOptions, + returnPartialData: true, + id, + query, + optimistic, }; + + let latestDiff: Cache.DiffResult | undefined; + + return new Observable((observer) => { + const callback = (diff: Cache.DiffResult) => { + const data = diff.result == null ? ({} as TData) : diff.result; + + if (latestDiff && equal(latestDiff.result, data)) { + return; + } + + const result = { + data, + complete: !!diff.complete, + missing: diff.missing, + }; + + latestDiff = { + ...diff, + result: data, + }; + + observer.next(result); + }; + + const watchOptions: Cache.WatchOptions = { + ...diffOptions, + immediate: true, + callback, + }; + + // Use ForestRun fragment optimization: register directly in + // fragmentWatches keyed by entity ID for efficient lookups + // via collectAffectedWatches / transaction.affectedNodes + if (this.env.optimizeFragmentReads && id) { + const initialDiff = this.diff(watchOptions); + if (this.shouldNotifyWatch(watchOptions, initialDiff)) { + this.notifyWatch(watchOptions, initialDiff); + } + accumulate(this.store.fragmentWatches, id, watchOptions); + return () => { + deleteAccumulated(this.store.fragmentWatches, id, watchOptions); + }; + } + + // Fall back to standard operation watching + return this.watchOperation(watchOptions); + }); } // Compatibility with InMemoryCache for Apollo dev tools diff --git a/packages/apollo-forest-run/src/__tests__/watchFragment.test.ts b/packages/apollo-forest-run/src/__tests__/watchFragment.test.ts new file mode 100644 index 000000000..24e921c70 --- /dev/null +++ b/packages/apollo-forest-run/src/__tests__/watchFragment.test.ts @@ -0,0 +1,401 @@ +import { DocumentNode } from "graphql"; +import { ForestRun } from "../ForestRun"; +import { gql } from "./helpers/descriptor"; + +function newCache(optimizeFragmentReads = false) { + return new ForestRun({ optimizeFragmentReads }); +} + +const QUERY = gql` + query ($id: Int!) { + item(id: $id) { + id + foo + } + } +`; + +const FRAGMENT: DocumentNode = { + kind: "Document", + definitions: [ + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "ItemFragment" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "Foo" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "foo" } }, + ], + }, + }, + ], +}; + +// zen-observable delivers values via Promise.resolve().then(...) +function waitForNextTick() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("watchFragment", () => { + test("returns an Observable with subscribe method", () => { + const cache = newCache(); + const observable = cache.watchFragment({ + fragment: FRAGMENT, + from: { __typename: "Foo", id: 1 }, + }); + expect(observable).toBeDefined(); + expect(typeof observable.subscribe).toBe("function"); + }); + + test("delivers initial result on subscribe with string id", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "bar" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + + expect(results.length).toBe(1); + expect(results[0].data).toEqual(foo1); + expect(results[0].complete).toBe(true); + + sub.unsubscribe(); + }); + + test("delivers initial result on subscribe with StoreObject", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "bar" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: { __typename: "Foo", id: 1 }, + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + + expect(results.length).toBe(1); + expect(results[0].data).toEqual(foo1); + expect(results[0].complete).toBe(true); + + sub.unsubscribe(); + }); + + test("delivers initial result on subscribe with Reference", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "bar" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: { __ref: "Foo:1" }, + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + + expect(results.length).toBe(1); + expect(results[0].data).toEqual(foo1); + expect(results[0].complete).toBe(true); + + sub.unsubscribe(); + }); + + test("notifies subscriber when fragment data changes", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "before" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + expect(results[0].data.foo).toBe("before"); + + const foo1changed = { __typename: "Foo", id: 1, foo: "after" }; + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1changed }, + variables: { id: 1 }, + }); + + await waitForNextTick(); + expect(results.length).toBe(2); + expect(results[1].data.foo).toBe("after"); + expect(results[1].complete).toBe(true); + + sub.unsubscribe(); + }); + + test("does not notify when data is unchanged (deduplication)", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "same" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + + // Write the same data again + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: { ...foo1 } }, + variables: { id: 1 }, + }); + + await waitForNextTick(); + // Should still be 1 because data didn't change + expect(results.length).toBe(1); + + sub.unsubscribe(); + }); + + test("stops notifying after unsubscribe", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "before" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + sub.unsubscribe(); + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: { __typename: "Foo", id: 1, foo: "after" } }, + variables: { id: 1 }, + }); + + await waitForNextTick(); + // Should still be 1 - no notification after unsubscribe + expect(results.length).toBe(1); + }); + + test("result has complete: false when data is missing", async () => { + const cache = newCache(); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + expect(results[0].complete).toBe(false); + + sub.unsubscribe(); + }); + + test("handles from === undefined gracefully", async () => { + const cache = newCache(); + + const results: any[] = []; + // TypeScript wouldn't normally allow this but JS callers might pass undefined + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: undefined as any, + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + // Should not throw, should deliver a result + expect(results.length).toBe(1); + + sub.unsubscribe(); + }); + + test("works with optimizeFragmentReads enabled", async () => { + const cache = newCache(true); + const foo1 = { __typename: "Foo", id: 1, foo: "bar" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + // Should deliver initial result even with optimizeFragmentReads + expect(results.length).toBe(1); + expect(results[0].data).toEqual(foo1); + expect(results[0].complete).toBe(true); + + sub.unsubscribe(); + }); + + test("delivers updates with optimizeFragmentReads enabled", async () => { + const cache = newCache(true); + const foo1 = { __typename: "Foo", id: 1, foo: "before" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + + const foo1changed = { __typename: "Foo", id: 1, foo: "after" }; + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1changed }, + variables: { id: 1 }, + }); + + await waitForNextTick(); + expect(results.length).toBe(2); + expect(results[1].data.foo).toBe("after"); + + sub.unsubscribe(); + }); + + test("result does not contain dataState field", async () => { + const cache = newCache(); + const foo1 = { __typename: "Foo", id: 1, foo: "bar" }; + + cache.write({ + dataId: "ROOT_QUERY", + query: QUERY, + result: { item: foo1 }, + variables: { id: 1 }, + }); + + const results: any[] = []; + const sub = cache + .watchFragment({ + fragment: FRAGMENT, + from: "Foo:1", + }) + .subscribe((result) => { + results.push(result); + }); + + await waitForNextTick(); + expect(results.length).toBe(1); + expect(results[0]).not.toHaveProperty("dataState"); + expect(results[0]).toHaveProperty("data"); + expect(results[0]).toHaveProperty("complete"); + + sub.unsubscribe(); + }); +});