From a01e1e193af985114c3d5491aa8c8b1b759dda2b Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 11 Mar 2026 18:03:53 +0100 Subject: [PATCH 01/20] proper cross-operation selection comparison --- .../data/queries/comments-list.graphql | 37 + .../data/queries/post-header.graphql | 29 + .../data/queries/post-preloader.graphql | 64 ++ .../data/responses/comments-list.json | 792 +++++++++++++++++ .../data/responses/post-header.json | 29 + .../data/responses/post-preloader.json | 815 ++++++++++++++++++ .../src/scenarios.ts | 77 ++ .../__tests__/resolvedSelection.test.ts | 608 ++++++++++++- .../src/descriptor/resolvedSelection.ts | 280 +++++- 9 files changed, 2708 insertions(+), 23 deletions(-) create mode 100644 packages/apollo-forest-run-benchmark/data/queries/comments-list.graphql create mode 100644 packages/apollo-forest-run-benchmark/data/queries/post-header.graphql create mode 100644 packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql create mode 100644 packages/apollo-forest-run-benchmark/data/responses/comments-list.json create mode 100644 packages/apollo-forest-run-benchmark/data/responses/post-header.json create mode 100644 packages/apollo-forest-run-benchmark/data/responses/post-preloader.json diff --git a/packages/apollo-forest-run-benchmark/data/queries/comments-list.graphql b/packages/apollo-forest-run-benchmark/data/queries/comments-list.graphql new file mode 100644 index 000000000..1f8f10b6f --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/queries/comments-list.graphql @@ -0,0 +1,37 @@ +query CommentsList($postId: ID!, $first: Int!, $after: String) { + ...CommentsListFragment +} + +fragment CommentsListFragment on Query { + post(id: $postId) { + id + comments(first: $first, after: $after) { + edges { + node { + id + body + createdAt + author { + id + name + avatarUrl + } + reactions { + id + type + user { + id + name + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } + } +} diff --git a/packages/apollo-forest-run-benchmark/data/queries/post-header.graphql b/packages/apollo-forest-run-benchmark/data/queries/post-header.graphql new file mode 100644 index 000000000..83409f5fe --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/queries/post-header.graphql @@ -0,0 +1,29 @@ +query PostHeader($postId: ID!) { + ...PostHeaderFragment +} + +fragment PostHeaderFragment on Query { + post(id: $postId) { + id + title + subtitle + publishedAt + coverImageUrl + author { + id + name + avatarUrl + bio + } + tags { + id + name + slug + } + stats { + viewCount + likeCount + commentCount + } + } +} diff --git a/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql b/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql new file mode 100644 index 000000000..e3b47288e --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql @@ -0,0 +1,64 @@ +query PostPreloader($postId: ID!, $first: Int!, $after: String) { + ...PostHeaderFragment + ...CommentsListFragment +} + +fragment PostHeaderFragment on Query { + post(id: $postId) { + id + title + subtitle + publishedAt + coverImageUrl + author { + id + name + avatarUrl + bio + } + tags { + id + name + slug + } + stats { + viewCount + likeCount + commentCount + } + } +} + +fragment CommentsListFragment on Query { + post(id: $postId) { + id + comments(first: $first, after: $after) { + edges { + node { + id + body + createdAt + author { + id + name + avatarUrl + } + reactions { + id + type + user { + id + name + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } + } +} diff --git a/packages/apollo-forest-run-benchmark/data/responses/comments-list.json b/packages/apollo-forest-run-benchmark/data/responses/comments-list.json new file mode 100644 index 000000000..0ec0f70f0 --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/responses/comments-list.json @@ -0,0 +1,792 @@ +{ + "post": { + "__typename": "Post", + "id": "post_1", + "comments": { + "__typename": "CommentConnection", + "edges": [ + { + "__typename": "CommentEdge", + "cursor": "cursor_1", + "node": { + "__typename": "Comment", + "id": "comment_1", + "body": "This is the best explanation of the event loop I've ever read. The diagram showing microtask queue priority was especially helpful.", + "createdAt": "2024-01-15T10:12:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_1", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_2", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_3", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_2", + "node": { + "__typename": "Comment", + "id": "comment_2", + "body": "I've been writing JS for 8 years and still learned something new here. Great work!", + "createdAt": "2024-01-15T10:34:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_4", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_5", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_3", + "node": { + "__typename": "Comment", + "id": "comment_3", + "body": "Could you do a follow-up on how Node.js handles the event loop differently from browsers?", + "createdAt": "2024-01-15T10:58:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_6", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_7", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_8", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_4", + "node": { + "__typename": "Comment", + "id": "comment_4", + "body": "The section on requestAnimationFrame timing was eye-opening. I had been misusing it in my rendering pipeline.", + "createdAt": "2024-01-15T11:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_9", "type": "HEART", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_5", + "node": { + "__typename": "Comment", + "id": "comment_5", + "body": "Thanks for the kind words everyone! A Node.js follow-up is definitely on my list — the libuv differences are fascinating.", + "createdAt": "2024-01-15T11:42:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_10", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_11", "type": "HEART", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_12", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } }, + { "__typename": "Reaction", "id": "reaction_13", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_6", + "node": { + "__typename": "Comment", + "id": "comment_6", + "body": "I ran into a bug last week that was exactly the microtask vs macrotask ordering issue you described. Wish I had read this sooner.", + "createdAt": "2024-01-15T12:03:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_14", "type": "LAUGH", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } }, + { "__typename": "Reaction", "id": "reaction_15", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_7", + "node": { + "__typename": "Comment", + "id": "comment_7", + "body": "Does anyone know how Web Workers fit into this model? They have their own event loop, right?", + "createdAt": "2024-01-15T12:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_16", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_8", + "node": { + "__typename": "Comment", + "id": "comment_8", + "body": "The comparison between setTimeout(0) and queueMicrotask finally clicked for me. Bookmarked this for reference.", + "createdAt": "2024-01-15T12:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_17", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_9", + "node": { + "__typename": "Comment", + "id": "comment_9", + "body": "Sharing this with my team. We've been debating whether to use Promises or setTimeout for batching, and this article settles it.", + "createdAt": "2024-01-15T13:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_18", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_19", "type": "HEART", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_10", + "node": { + "__typename": "Comment", + "id": "comment_10", + "body": "Excellent article. One small correction: the HTML spec actually calls them 'tasks', not 'macrotasks', though the community uses both terms.", + "createdAt": "2024-01-15T13:38:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_20", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_21", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_11", + "node": { + "__typename": "Comment", + "id": "comment_11", + "body": "I used to think async/await magically made things synchronous. This cleared up so many misconceptions.", + "createdAt": "2024-01-15T14:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_12", + "node": { + "__typename": "Comment", + "id": "comment_12", + "body": "The visual diagram of the call stack and task queues should be in every JS textbook.", + "createdAt": "2024-01-15T14:22:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_22", "type": "HEART", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } }, + { "__typename": "Reaction", "id": "reaction_23", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_13", + "node": { + "__typename": "Comment", + "id": "comment_13", + "body": "How does this interact with React's batching in concurrent mode? I imagine the scheduler uses similar primitives.", + "createdAt": "2024-01-15T14:50:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_24", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_14", + "node": { + "__typename": "Comment", + "id": "comment_14", + "body": "I tested the examples in Chrome DevTools and the output matched exactly. Really well researched piece.", + "createdAt": "2024-01-15T15:14:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_25", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_26", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_15", + "node": { + "__typename": "Comment", + "id": "comment_15", + "body": "We had a production bug where MutationObserver callbacks were firing before our Promise handlers. This explains why.", + "createdAt": "2024-01-15T15:40:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_27", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_16", + "node": { + "__typename": "Comment", + "id": "comment_16", + "body": "Would love to see a benchmark comparing different async patterns and their impact on frame budget. Have you measured any of this?", + "createdAt": "2024-01-15T16:08:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_17", + "node": { + "__typename": "Comment", + "id": "comment_17", + "body": "The part about starvation when microtasks keep queueing more microtasks was a lightbulb moment for me.", + "createdAt": "2024-01-15T16:35:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_28", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_29", "type": "LAUGH", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_18", + "node": { + "__typename": "Comment", + "id": "comment_18", + "body": "Great question about React concurrent mode! Yes, React's scheduler uses MessageChannel under the hood for similar reasons to what I described.", + "createdAt": "2024-01-15T17:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_30", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_31", "type": "HEART", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_19", + "node": { + "__typename": "Comment", + "id": "comment_19", + "body": "This is required reading for anyone doing performance optimization on the frontend.", + "createdAt": "2024-01-15T17:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_32", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_20", + "node": { + "__typename": "Comment", + "id": "comment_20", + "body": "I'd add that the Scheduler API (scheduler.postTask) is worth mentioning as it gives more fine-grained control over task priority.", + "createdAt": "2024-01-15T17:55:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_33", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_34", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_35", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_21", + "node": { + "__typename": "Comment", + "id": "comment_21", + "body": "Sent this to my junior devs. The examples are clear enough for someone just learning async JS.", + "createdAt": "2024-01-15T18:20:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_36", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_22", + "node": { + "__typename": "Comment", + "id": "comment_22", + "body": "The interactive code playground at the end is a nice touch. Being able to step through the event loop manually really helps.", + "createdAt": "2024-01-15T18:48:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_37", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_38", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_23", + "node": { + "__typename": "Comment", + "id": "comment_23", + "body": "One thing worth noting: in Deno, the event loop behavior can differ slightly for top-level await.", + "createdAt": "2024-01-15T19:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_24", + "node": { + "__typename": "Comment", + "id": "comment_24", + "body": "I've been using MessageChannel for yielding to the browser and this validates the approach. Thanks for the thorough explanation.", + "createdAt": "2024-01-15T19:42:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_39", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_25", + "node": { + "__typename": "Comment", + "id": "comment_25", + "body": "The microtask checkpoint after each task is something most articles gloss over. Great detail here.", + "createdAt": "2024-01-15T20:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_40", "type": "LIKE", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_41", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_26", + "node": { + "__typename": "Comment", + "id": "comment_26", + "body": "Does anyone have a good mental model for when to choose queueMicrotask vs requestIdleCallback vs scheduler.postTask?", + "createdAt": "2024-01-16T08:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_42", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_27", + "node": { + "__typename": "Comment", + "id": "comment_27", + "body": "I refactored our analytics pipeline after reading this. Moved from Promises to requestIdleCallback and saw a 15ms improvement in INP.", + "createdAt": "2024-01-16T08:32:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_43", "type": "HEART", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } }, + { "__typename": "Reaction", "id": "reaction_44", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_28", + "node": { + "__typename": "Comment", + "id": "comment_28", + "body": "Your explanation of why Promise.resolve().then() runs before setTimeout(fn, 0) is the clearest I've seen anywhere.", + "createdAt": "2024-01-16T09:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_45", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_29", + "node": { + "__typename": "Comment", + "id": "comment_29", + "body": "Small note: Firefox and Safari handle some edge cases differently from Chrome, especially around nested microtask flushing.", + "createdAt": "2024-01-16T09:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_46", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_30", + "node": { + "__typename": "Comment", + "id": "comment_30", + "body": "Just used this article to ace a technical interview question about event loop ordering. Thank you!", + "createdAt": "2024-01-16T10:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_47", "type": "LAUGH", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_48", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_31", + "node": { + "__typename": "Comment", + "id": "comment_31", + "body": "Ha, congrats on the interview! These fundamentals come up surprisingly often in senior-level discussions too.", + "createdAt": "2024-01-16T10:30:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_49", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_50", "type": "HEART", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_32", + "node": { + "__typename": "Comment", + "id": "comment_32", + "body": "The way you linked the spec references inline is really helpful for anyone wanting to go deeper.", + "createdAt": "2024-01-16T11:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_33", + "node": { + "__typename": "Comment", + "id": "comment_33", + "body": "I wonder how the upcoming Temporal API will interact with the event loop scheduling. Any thoughts?", + "createdAt": "2024-01-16T11:38:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_51", "type": "LIKE", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_34", + "node": { + "__typename": "Comment", + "id": "comment_34", + "body": "This finally explains the inconsistent behavior I was seeing with IntersectionObserver callbacks.", + "createdAt": "2024-01-16T12:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_52", "type": "LIKE", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_53", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_35", + "node": { + "__typename": "Comment", + "id": "comment_35", + "body": "Our team switched from recursive setTimeout to setInterval after reading the timing guarantees section. Much more predictable now.", + "createdAt": "2024-01-16T12:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_54", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_36", + "node": { + "__typename": "Comment", + "id": "comment_36", + "body": "Curious about the performance characteristics of AbortController signal handling within the event loop model.", + "createdAt": "2024-01-16T13:20:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_55", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_37", + "node": { + "__typename": "Comment", + "id": "comment_37", + "body": "I built a task scheduler for our app inspired by the patterns in this article. Reduced jank by 40% on low-end devices.", + "createdAt": "2024-01-16T14:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_56", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_57", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_58", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_38", + "node": { + "__typename": "Comment", + "id": "comment_38", + "body": "The section on how input events get special priority in the event loop was new to me. Really useful for building responsive UIs.", + "createdAt": "2024-01-17T08:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_59", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_39", + "node": { + "__typename": "Comment", + "id": "comment_39", + "body": "Bookmarking this alongside Jake Archibald's talk on the event loop. Together they cover everything you need to know.", + "createdAt": "2024-01-17T09:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_60", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_40", + "node": { + "__typename": "Comment", + "id": "comment_40", + "body": "Just discovered that structuredClone uses a microtask internally. This article gave me the framework to understand why that matters.", + "createdAt": "2024-01-17T09:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [] + } + } + ], + "pageInfo": { + "__typename": "PageInfo", + "hasNextPage": true, + "endCursor": "cursor_40" + }, + "totalCount": 87 + } + } +} diff --git a/packages/apollo-forest-run-benchmark/data/responses/post-header.json b/packages/apollo-forest-run-benchmark/data/responses/post-header.json new file mode 100644 index 000000000..f73e4e434 --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/responses/post-header.json @@ -0,0 +1,29 @@ +{ + "post": { + "__typename": "Post", + "id": "post_1", + "title": "Understanding the Event Loop: A Deep Dive into Asynchronous JavaScript", + "subtitle": "How microtasks, macrotasks, and rendering interleave in modern browsers", + "publishedAt": "2024-01-15T09:30:00.000Z", + "coverImageUrl": "https://images.example.com/covers/event-loop-deep-dive.jpg", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg", + "bio": "Staff engineer at Acme Corp. Writing about JavaScript internals, performance, and developer tooling." + }, + "tags": [ + { "__typename": "Tag", "id": "tag_1", "name": "JavaScript", "slug": "javascript" }, + { "__typename": "Tag", "id": "tag_2", "name": "Performance", "slug": "performance" }, + { "__typename": "Tag", "id": "tag_3", "name": "Browser Internals", "slug": "browser-internals" }, + { "__typename": "Tag", "id": "tag_4", "name": "Async Programming", "slug": "async-programming" } + ], + "stats": { + "__typename": "PostStats", + "viewCount": 12847, + "likeCount": 342, + "commentCount": 87 + } + } +} diff --git a/packages/apollo-forest-run-benchmark/data/responses/post-preloader.json b/packages/apollo-forest-run-benchmark/data/responses/post-preloader.json new file mode 100644 index 000000000..46a9849f1 --- /dev/null +++ b/packages/apollo-forest-run-benchmark/data/responses/post-preloader.json @@ -0,0 +1,815 @@ +{ + "post": { + "__typename": "Post", + "id": "post_1", + "title": "Understanding the Event Loop: A Deep Dive into Asynchronous JavaScript", + "subtitle": "How microtasks, macrotasks, and rendering interleave in modern browsers", + "publishedAt": "2024-01-15T09:30:00.000Z", + "coverImageUrl": "https://images.example.com/covers/event-loop-deep-dive.jpg", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg", + "bio": "Staff engineer at Acme Corp. Writing about JavaScript internals, performance, and developer tooling." + }, + "tags": [ + { "__typename": "Tag", "id": "tag_1", "name": "JavaScript", "slug": "javascript" }, + { "__typename": "Tag", "id": "tag_2", "name": "Performance", "slug": "performance" }, + { "__typename": "Tag", "id": "tag_3", "name": "Browser Internals", "slug": "browser-internals" }, + { "__typename": "Tag", "id": "tag_4", "name": "Async Programming", "slug": "async-programming" } + ], + "stats": { + "__typename": "PostStats", + "viewCount": 12847, + "likeCount": 342, + "commentCount": 87 + }, + "comments": { + "__typename": "CommentConnection", + "edges": [ + { + "__typename": "CommentEdge", + "cursor": "cursor_1", + "node": { + "__typename": "Comment", + "id": "comment_1", + "body": "This is the best explanation of the event loop I've ever read. The diagram showing microtask queue priority was especially helpful.", + "createdAt": "2024-01-15T10:12:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_1", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_2", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_3", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_2", + "node": { + "__typename": "Comment", + "id": "comment_2", + "body": "I've been writing JS for 8 years and still learned something new here. Great work!", + "createdAt": "2024-01-15T10:34:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_4", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_5", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_3", + "node": { + "__typename": "Comment", + "id": "comment_3", + "body": "Could you do a follow-up on how Node.js handles the event loop differently from browsers?", + "createdAt": "2024-01-15T10:58:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_6", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_7", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_8", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_4", + "node": { + "__typename": "Comment", + "id": "comment_4", + "body": "The section on requestAnimationFrame timing was eye-opening. I had been misusing it in my rendering pipeline.", + "createdAt": "2024-01-15T11:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_9", "type": "HEART", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_5", + "node": { + "__typename": "Comment", + "id": "comment_5", + "body": "Thanks for the kind words everyone! A Node.js follow-up is definitely on my list — the libuv differences are fascinating.", + "createdAt": "2024-01-15T11:42:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_10", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_11", "type": "HEART", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_12", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } }, + { "__typename": "Reaction", "id": "reaction_13", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_6", + "node": { + "__typename": "Comment", + "id": "comment_6", + "body": "I ran into a bug last week that was exactly the microtask vs macrotask ordering issue you described. Wish I had read this sooner.", + "createdAt": "2024-01-15T12:03:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_14", "type": "LAUGH", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } }, + { "__typename": "Reaction", "id": "reaction_15", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_7", + "node": { + "__typename": "Comment", + "id": "comment_7", + "body": "Does anyone know how Web Workers fit into this model? They have their own event loop, right?", + "createdAt": "2024-01-15T12:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_16", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_8", + "node": { + "__typename": "Comment", + "id": "comment_8", + "body": "The comparison between setTimeout(0) and queueMicrotask finally clicked for me. Bookmarked this for reference.", + "createdAt": "2024-01-15T12:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_17", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_9", + "node": { + "__typename": "Comment", + "id": "comment_9", + "body": "Sharing this with my team. We've been debating whether to use Promises or setTimeout for batching, and this article settles it.", + "createdAt": "2024-01-15T13:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_18", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_19", "type": "HEART", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_10", + "node": { + "__typename": "Comment", + "id": "comment_10", + "body": "Excellent article. One small correction: the HTML spec actually calls them 'tasks', not 'macrotasks', though the community uses both terms.", + "createdAt": "2024-01-15T13:38:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_20", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_21", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_11", + "node": { + "__typename": "Comment", + "id": "comment_11", + "body": "I used to think async/await magically made things synchronous. This cleared up so many misconceptions.", + "createdAt": "2024-01-15T14:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_12", + "node": { + "__typename": "Comment", + "id": "comment_12", + "body": "The visual diagram of the call stack and task queues should be in every JS textbook.", + "createdAt": "2024-01-15T14:22:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_22", "type": "HEART", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } }, + { "__typename": "Reaction", "id": "reaction_23", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_13", + "node": { + "__typename": "Comment", + "id": "comment_13", + "body": "How does this interact with React's batching in concurrent mode? I imagine the scheduler uses similar primitives.", + "createdAt": "2024-01-15T14:50:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_24", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_14", + "node": { + "__typename": "Comment", + "id": "comment_14", + "body": "I tested the examples in Chrome DevTools and the output matched exactly. Really well researched piece.", + "createdAt": "2024-01-15T15:14:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_25", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_26", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_15", + "node": { + "__typename": "Comment", + "id": "comment_15", + "body": "We had a production bug where MutationObserver callbacks were firing before our Promise handlers. This explains why.", + "createdAt": "2024-01-15T15:40:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_27", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_16", + "node": { + "__typename": "Comment", + "id": "comment_16", + "body": "Would love to see a benchmark comparing different async patterns and their impact on frame budget. Have you measured any of this?", + "createdAt": "2024-01-15T16:08:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_17", + "node": { + "__typename": "Comment", + "id": "comment_17", + "body": "The part about starvation when microtasks keep queueing more microtasks was a lightbulb moment for me.", + "createdAt": "2024-01-15T16:35:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_28", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_29", "type": "LAUGH", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_18", + "node": { + "__typename": "Comment", + "id": "comment_18", + "body": "Great question about React concurrent mode! Yes, React's scheduler uses MessageChannel under the hood for similar reasons to what I described.", + "createdAt": "2024-01-15T17:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_30", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } }, + { "__typename": "Reaction", "id": "reaction_31", "type": "HEART", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_19", + "node": { + "__typename": "Comment", + "id": "comment_19", + "body": "This is required reading for anyone doing performance optimization on the frontend.", + "createdAt": "2024-01-15T17:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_32", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_20", + "node": { + "__typename": "Comment", + "id": "comment_20", + "body": "I'd add that the Scheduler API (scheduler.postTask) is worth mentioning as it gives more fine-grained control over task priority.", + "createdAt": "2024-01-15T17:55:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_33", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_34", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_35", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_21", + "node": { + "__typename": "Comment", + "id": "comment_21", + "body": "Sent this to my junior devs. The examples are clear enough for someone just learning async JS.", + "createdAt": "2024-01-15T18:20:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_36", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_22", + "node": { + "__typename": "Comment", + "id": "comment_22", + "body": "The interactive code playground at the end is a nice touch. Being able to step through the event loop manually really helps.", + "createdAt": "2024-01-15T18:48:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_37", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_38", "type": "LIKE", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_23", + "node": { + "__typename": "Comment", + "id": "comment_23", + "body": "One thing worth noting: in Deno, the event loop behavior can differ slightly for top-level await.", + "createdAt": "2024-01-15T19:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_24", + "node": { + "__typename": "Comment", + "id": "comment_24", + "body": "I've been using MessageChannel for yielding to the browser and this validates the approach. Thanks for the thorough explanation.", + "createdAt": "2024-01-15T19:42:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_39", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_25", + "node": { + "__typename": "Comment", + "id": "comment_25", + "body": "The microtask checkpoint after each task is something most articles gloss over. Great detail here.", + "createdAt": "2024-01-15T20:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_40", "type": "LIKE", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_41", "type": "LIKE", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_26", + "node": { + "__typename": "Comment", + "id": "comment_26", + "body": "Does anyone have a good mental model for when to choose queueMicrotask vs requestIdleCallback vs scheduler.postTask?", + "createdAt": "2024-01-16T08:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_42", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_27", + "node": { + "__typename": "Comment", + "id": "comment_27", + "body": "I refactored our analytics pipeline after reading this. Moved from Promises to requestIdleCallback and saw a 15ms improvement in INP.", + "createdAt": "2024-01-16T08:32:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_43", "type": "HEART", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } }, + { "__typename": "Reaction", "id": "reaction_44", "type": "LIKE", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_28", + "node": { + "__typename": "Comment", + "id": "comment_28", + "body": "Your explanation of why Promise.resolve().then() runs before setTimeout(fn, 0) is the clearest I've seen anywhere.", + "createdAt": "2024-01-16T09:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_45", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_29", + "node": { + "__typename": "Comment", + "id": "comment_29", + "body": "Small note: Firefox and Safari handle some edge cases differently from Chrome, especially around nested microtask flushing.", + "createdAt": "2024-01-16T09:28:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_46", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_30", + "node": { + "__typename": "Comment", + "id": "comment_30", + "body": "Just used this article to ace a technical interview question about event loop ordering. Thank you!", + "createdAt": "2024-01-16T10:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_47", "type": "LAUGH", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } }, + { "__typename": "Reaction", "id": "reaction_48", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_31", + "node": { + "__typename": "Comment", + "id": "comment_31", + "body": "Ha, congrats on the interview! These fundamentals come up surprisingly often in senior-level discussions too.", + "createdAt": "2024-01-16T10:30:00.000Z", + "author": { + "__typename": "User", + "id": "user_author_1", + "name": "Elena Kowalski", + "avatarUrl": "https://avatars.example.com/user_author_1.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_49", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } }, + { "__typename": "Reaction", "id": "reaction_50", "type": "HEART", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_32", + "node": { + "__typename": "Comment", + "id": "comment_32", + "body": "The way you linked the spec references inline is really helpful for anyone wanting to go deeper.", + "createdAt": "2024-01-16T11:05:00.000Z", + "author": { + "__typename": "User", + "id": "user_3", + "name": "Priya Sharma", + "avatarUrl": "https://avatars.example.com/user_3.jpg" + }, + "reactions": [] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_33", + "node": { + "__typename": "Comment", + "id": "comment_33", + "body": "I wonder how the upcoming Temporal API will interact with the event loop scheduling. Any thoughts?", + "createdAt": "2024-01-16T11:38:00.000Z", + "author": { + "__typename": "User", + "id": "user_4", + "name": "James O'Brien", + "avatarUrl": "https://avatars.example.com/user_4.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_51", "type": "LIKE", "user": { "__typename": "User", "id": "user_8", "name": "Ricardo Mendes" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_34", + "node": { + "__typename": "Comment", + "id": "comment_34", + "body": "This finally explains the inconsistent behavior I was seeing with IntersectionObserver callbacks.", + "createdAt": "2024-01-16T12:10:00.000Z", + "author": { + "__typename": "User", + "id": "user_5", + "name": "Sofia Andersson", + "avatarUrl": "https://avatars.example.com/user_5.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_52", "type": "LIKE", "user": { "__typename": "User", "id": "user_7", "name": "Aisha Patel" } }, + { "__typename": "Reaction", "id": "reaction_53", "type": "LIKE", "user": { "__typename": "User", "id": "user_4", "name": "James O'Brien" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_35", + "node": { + "__typename": "Comment", + "id": "comment_35", + "body": "Our team switched from recursive setTimeout to setInterval after reading the timing guarantees section. Much more predictable now.", + "createdAt": "2024-01-16T12:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_6", + "name": "Tomasz Nowak", + "avatarUrl": "https://avatars.example.com/user_6.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_54", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_9", "name": "Yuki Tanaka" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_36", + "node": { + "__typename": "Comment", + "id": "comment_36", + "body": "Curious about the performance characteristics of AbortController signal handling within the event loop model.", + "createdAt": "2024-01-16T13:20:00.000Z", + "author": { + "__typename": "User", + "id": "user_7", + "name": "Aisha Patel", + "avatarUrl": "https://avatars.example.com/user_7.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_55", "type": "LIKE", "user": { "__typename": "User", "id": "user_5", "name": "Sofia Andersson" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_37", + "node": { + "__typename": "Comment", + "id": "comment_37", + "body": "I built a task scheduler for our app inspired by the patterns in this article. Reduced jank by 40% on low-end devices.", + "createdAt": "2024-01-16T14:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_8", + "name": "Ricardo Mendes", + "avatarUrl": "https://avatars.example.com/user_8.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_56", "type": "HEART", "user": { "__typename": "User", "id": "user_author_1", "name": "Elena Kowalski" } }, + { "__typename": "Reaction", "id": "reaction_57", "type": "LIKE", "user": { "__typename": "User", "id": "user_6", "name": "Tomasz Nowak" } }, + { "__typename": "Reaction", "id": "reaction_58", "type": "THUMBS_UP", "user": { "__typename": "User", "id": "user_3", "name": "Priya Sharma" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_38", + "node": { + "__typename": "Comment", + "id": "comment_38", + "body": "The section on how input events get special priority in the event loop was new to me. Really useful for building responsive UIs.", + "createdAt": "2024-01-17T08:15:00.000Z", + "author": { + "__typename": "User", + "id": "user_9", + "name": "Yuki Tanaka", + "avatarUrl": "https://avatars.example.com/user_9.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_59", "type": "LIKE", "user": { "__typename": "User", "id": "user_10", "name": "Fatima Al-Rashid" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_39", + "node": { + "__typename": "Comment", + "id": "comment_39", + "body": "Bookmarking this alongside Jake Archibald's talk on the event loop. Together they cover everything you need to know.", + "createdAt": "2024-01-17T09:00:00.000Z", + "author": { + "__typename": "User", + "id": "user_10", + "name": "Fatima Al-Rashid", + "avatarUrl": "https://avatars.example.com/user_10.jpg" + }, + "reactions": [ + { "__typename": "Reaction", "id": "reaction_60", "type": "LIKE", "user": { "__typename": "User", "id": "user_2", "name": "Marcus Chen" } } + ] + } + }, + { + "__typename": "CommentEdge", + "cursor": "cursor_40", + "node": { + "__typename": "Comment", + "id": "comment_40", + "body": "Just discovered that structuredClone uses a microtask internally. This article gave me the framework to understand why that matters.", + "createdAt": "2024-01-17T09:45:00.000Z", + "author": { + "__typename": "User", + "id": "user_2", + "name": "Marcus Chen", + "avatarUrl": "https://avatars.example.com/user_2.jpg" + }, + "reactions": [] + } + } + ], + "pageInfo": { + "__typename": "PageInfo", + "hasNextPage": true, + "endCursor": "cursor_40" + }, + "totalCount": 87 + } + } +} diff --git a/packages/apollo-forest-run-benchmark/src/scenarios.ts b/packages/apollo-forest-run-benchmark/src/scenarios.ts index f316d00d1..a7fbd9fb3 100644 --- a/packages/apollo-forest-run-benchmark/src/scenarios.ts +++ b/packages/apollo-forest-run-benchmark/src/scenarios.ts @@ -327,4 +327,81 @@ export const scenarios = [ }; }, }, + { + name: "read-list-from-preloader", + prepare: (ctx: ScenarioContext) => { + const { operations, CacheFactory, configuration } = ctx; + const cache = new CacheFactory(configuration); + const preloader = operations["post-preloader"]; + const list = operations["comments-list"]; + const variables = { postId: "post_1", first: 40, after: null }; + + cache.writeQuery({ + query: preloader.query, + data: preloader.data["post-preloader"], + variables, + }); + + return { + run() { + return cache.readQuery({ query: list.query, variables }); + }, + }; + }, + }, + { + name: "read-header-from-preloader", + prepare: (ctx: ScenarioContext) => { + const { operations, CacheFactory, configuration } = ctx; + const cache = new CacheFactory(configuration); + const preloader = operations["post-preloader"]; + const header = operations["post-header"]; + const writeVars = { postId: "post_1", first: 40, after: null }; + const readVars = { postId: "post_1" }; + + cache.writeQuery({ + query: preloader.query, + data: preloader.data["post-preloader"], + variables: writeVars, + }); + + return { + run() { + return cache.readQuery({ query: header.query, variables: readVars }); + }, + }; + }, + }, + { + name: "read-preloader-from-parts", + prepare: (ctx: ScenarioContext) => { + const { operations, CacheFactory, configuration } = ctx; + const cache = new CacheFactory(configuration); + const preloader = operations["post-preloader"]; + const list = operations["comments-list"]; + const header = operations["post-header"]; + const listVars = { postId: "post_1", first: 40, after: null }; + const headerVars = { postId: "post_1" }; + + cache.writeQuery({ + query: list.query, + data: list.data["comments-list"], + variables: listVars, + }); + cache.writeQuery({ + query: header.query, + data: header.data["post-header"], + variables: headerVars, + }); + + return { + run() { + return cache.readQuery({ + query: preloader.query, + variables: listVars, + }); + }, + }; + }, + }, ] as const satisfies Scenario[]; diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index 23b565d45..59186c237 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -1,4 +1,7 @@ -import { createTestOperation } from "../../__tests__/helpers/descriptor"; +import { + createTestOperation, + getFieldInfo, +} from "../../__tests__/helpers/descriptor"; import { assert } from "../../jsutils/assert"; import { resolveSelection, @@ -959,6 +962,609 @@ describe(resolvedSelectionsAreEqual, () => { resolvedSelectionsAreEqual(resolvedSelectionA, resolvedSelectionB), ).toBe(true); }); + // --- Cross-operation comparisons (different documents, structurally equal) --- + + describe("cross-operation: different documents with identical selections", () => { + it("returns true for same flat fields from different documents", () => { + const opA = createTestOperation("query A { foo bar }"); + const opB = createTestOperation("query B { foo bar }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for same nested fields from different documents", () => { + const opA = createTestOperation("query A { foo { bar baz } }"); + const opB = createTestOperation("query B { foo { bar baz } }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for different fields from different documents", () => { + const opA = createTestOperation("query A { foo }"); + const opB = createTestOperation("query B { bar }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for fragment-spread vs equivalent inline selection", () => { + const opA = createTestOperation(` + query A { + ...F + } + fragment F on Query { + foo + bar + } + `); + const opB = createTestOperation("query B { foo bar }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for same fields with same args from different documents", () => { + const opA = createTestOperation("query A($x: Int!) { foo(arg: $x) }", { + x: 42, + }); + const opB = createTestOperation("query B($x: Int!) { foo(arg: $x) }", { + x: 42, + }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for same fields with different args from different documents", () => { + const opA = createTestOperation("query A($x: Int!) { foo(arg: $x) }", { + x: 1, + }); + const opB = createTestOperation("query B($x: Int!) { foo(arg: $x) }", { + x: 2, + }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when one document has extra fields", () => { + const opA = createTestOperation("query A { foo bar }"); + const opB = createTestOperation("query B { foo bar baz }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false for same top-level fields but different nested selections", () => { + const opA = createTestOperation("query A { foo { bar } }"); + const opB = createTestOperation("query B { foo { baz } }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for deeply nested identical selections from different documents", () => { + const opA = createTestOperation( + "query A { foo { bar { baz { id name } } } }", + ); + const opB = createTestOperation( + "query B { foo { bar { baz { id name } } } }", + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for same inline fragments from different documents", () => { + const opA = createTestOperation(` + query A { + node { + ... on User { name email } + ... on Post { title body } + } + } + `); + const opB = createTestOperation(` + query B { + node { + ... on User { name email } + ... on Post { title body } + } + } + `); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for different inline fragment types", () => { + const opA = createTestOperation(` + query A { + node { + ... on User { name } + } + } + `); + const opB = createTestOperation(` + query B { + node { + ... on Post { name } + } + } + `); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for same aliases from different documents", () => { + const opA = createTestOperation("query A { myFoo: foo myBar: bar }"); + const opB = createTestOperation("query B { myFoo: foo myBar: bar }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for different aliases on same fields from different documents", () => { + const opA = createTestOperation("query A { a: foo }"); + const opB = createTestOperation("query B { b: foo }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Child selection equality (the FIXME: variables affecting nested levels) --- + + describe("cross-operation: child selection differences via variables", () => { + it("returns false when variables cause different child @include", () => { + const query = ` + query($inc: Boolean!) { + foo { + bar @include(if: $inc) + baz + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when variables cause different child @skip", () => { + const query = ` + query($skip: Boolean!) { + foo { + bar @skip(if: $skip) + baz + } + } + `; + const opA = createTestOperation(query, { skip: true }); + const opB = createTestOperation(query, { skip: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when variables cause different child field args", () => { + const query = ` + query($x: Int!) { + foo { + bar(arg: $x) + } + } + `; + const opA = createTestOperation(query, { x: 1 }); + const opB = createTestOperation(query, { x: 2 }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when variables cause different deeply nested @include", () => { + const query = ` + query($inc: Boolean!) { + a { + b { + c @include(if: $inc) + } + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when variables cause different deeply nested args", () => { + const query = ` + query($x: String!) { + a { + b { + c(filter: $x) + } + } + } + `; + const opA = createTestOperation(query, { x: "active" }); + const opB = createTestOperation(query, { x: "archived" }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true when variables resolve to same child selections", () => { + const query = ` + query($inc: Boolean!) { + foo { + bar @include(if: $inc) + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: true }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false when variables differ only at leaf level of nested inline fragments", () => { + const query = ` + query($inc: Boolean!) { + node { + ... on User { + posts { + title @include(if: $inc) + body + } + } + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Group A: Typed selection basics --- + + describe("typed selections: basics", () => { + it("returns false comparing null vs typed selection from same op", () => { + const query = `{ id ... on User { name } }`; + const op = createTestOperation(query); + const selNull = resolveSelection(op, op.possibleSelections, null); + const selUser = resolveSelection(op, op.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selNull, selUser)).toBe(false); + }); + + it("returns true for same typed selection resolved twice from same op", () => { + const query = `{ id ... on User { name } }`; + const op = createTestOperation(query); + const selA = resolveSelection(op, op.possibleSelections, "User"); + const selB = resolveSelection(op, op.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false comparing different types from same op", () => { + const query = `{ id ... on User { name } ... on Post { title } }`; + const op = createTestOperation(query); + const selUser = resolveSelection(op, op.possibleSelections, "User"); + const selPost = resolveSelection(op, op.possibleSelections, "Post"); + + expect(resolvedSelectionsAreEqual(selUser, selPost)).toBe(false); + }); + + it("returns true when unknown type falls back to null selection", () => { + const query = `{ id ... on User { name } }`; + const op = createTestOperation(query); + const selUnknown = resolveSelection(op, op.possibleSelections, "Unknown"); + const selNull = resolveSelection(op, op.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selUnknown, selNull)).toBe(true); + }); + + it("returns true for merged same-type inline fragments", () => { + const query = `{ ... on User { name } ... on User { age } }`; + const op = createTestOperation(query); + const sel = resolveSelection(op, op.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(sel, sel)).toBe(true); + }); + }); + + // --- Group B: Typed selections with variables --- + + describe("typed selections: variables", () => { + it("returns false for typed @skip with different variables", () => { + const query = ` + query ($skip: Boolean!) { + ... on User { name @skip(if: $skip) age } + } + `; + const opA = createTestOperation(query, { skip: false }); + const opB = createTestOperation(query, { skip: true }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false for typed @include with different variables", () => { + const query = ` + query ($inc: Boolean!) { + ... on User { name @include(if: $inc) age } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false for typed field with different arg variables", () => { + const query = ` + query ($id: ID!) { + ... on User { posts(first: $id) { title } } + } + `; + const opA = createTestOperation(query, { id: "10" }); + const opB = createTestOperation(query, { id: "20" }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for typed selection with same variables", () => { + const query = ` + query ($inc: Boolean!) { + ... on User { name @include(if: $inc) } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: true }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + }); + + // --- Group C: Typed child selection false positives (FIXME) --- + + describe("typed selections: child selection false positives", () => { + it("returns false when typed child @include differs via variables", () => { + const query = ` + query ($inc: Boolean!) { + ... on User { posts { title @include(if: $inc) body } } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when typed child nested arg differs via variables", () => { + const query = ` + query ($cursor: String) { + ... on User { posts { comments(after: $cursor) { text } } } + } + `; + const opA = createTestOperation(query, { cursor: "abc" }); + const opB = createTestOperation(query, { cursor: "xyz" }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Group D: Cross-document typed selections (false negatives) --- + + describe("cross-document: typed selections", () => { + it("returns true for structurally identical typed selections from different docs", () => { + const opA = createTestOperation("query A { ... on User { name age } }"); + const opB = createTestOperation("query B { ... on User { name age } }"); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for typed selections with merged common fields from different docs", () => { + const opA = createTestOperation("query A { id ... on User { name } }"); + const opB = createTestOperation("query B { id ... on User { name } }"); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for fragment spread vs inline fragment producing same typed selection", () => { + const opA = createTestOperation(` + query A { ...UF } + fragment UF on User { name } + `); + const opB = createTestOperation("query B { ... on User { name } }"); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + }); + + // --- Group E: Directives on inline fragments and spreads --- + + describe("directives on inline fragments and spreads", () => { + it("returns false when @include on inline fragment differs via variables", () => { + const query = ` + query ($inc: Boolean!) { + ... on User @include(if: $inc) { name } + id + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, "User"); + const selB = resolveSelection(opB, opB.possibleSelections, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when @include on named spread differs via variables", () => { + const query = ` + query ($inc: Boolean!) { + ...F @include(if: $inc) + } + fragment F on Query { foo bar } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Group F: Nested typed selections via variables (false positives at root) --- + + describe("nested typed selections via variables", () => { + it("returns false at root when child typed selection differs via @include", () => { + const query = ` + query ($inc: Boolean!) { + node { + ... on User { bio @include(if: $inc) } + ... on Post { body } + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false at root when deeply nested child typed arg differs", () => { + const query = ` + query ($x: String!) { + node { + ... on User { + posts { + ... on BlogPost { comments(after: $x) { text } } + } + } + } + } + `; + const opA = createTestOperation(query, { x: "aaa" }); + const opB = createTestOperation(query, { x: "bbb" }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false when comparing child typed selection directly", () => { + const query = ` + query ($inc: Boolean!) { + node { + ... on User { bio @include(if: $inc) } + ... on Post { body } + } + } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + + const nodeFieldA = getFieldInfo(opA.possibleSelections, ["node"]); + const nodeFieldB = getFieldInfo(opB.possibleSelections, ["node"]); + assert(nodeFieldA.selection && nodeFieldB.selection); + + const selA = resolveSelection(opA, nodeFieldA.selection, "User"); + const selB = resolveSelection(opB, nodeFieldB.selection, "User"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Group G: Merged fragments at different depths --- + + describe("merged fragments at different depths", () => { + it("returns false when variable affects only a deep child of merged fragment", () => { + const query = ` + query ($inc: Boolean!) { + ...RootFields + ...DeepFields + } + fragment RootFields on Query { id } + fragment DeepFields on Query { foo { bar @include(if: $inc) baz } } + `; + const opA = createTestOperation(query, { inc: true }); + const opB = createTestOperation(query, { inc: false }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for structurally identical nested selections from different docs", () => { + const opA = createTestOperation("query A { foo { bar } baz }"); + const opB = createTestOperation("query B { foo { bar } baz }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for same top-level fields but different children from different docs", () => { + const opA = createTestOperation("query A { foo { bar } }"); + const opB = createTestOperation("query B { foo { qux } }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); }); describe(fieldEntriesAreEqual, () => { diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index b9e92879f..361dbe083 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -7,6 +7,7 @@ import type { KeySpecifier, NormalizedFieldEntry, OperationDescriptor, + PossibleSelection, PossibleSelections, ResolvedSelection, TypeName, @@ -28,6 +29,12 @@ import { createArgumentDefs } from "./possibleSelection"; const EMPTY_ARRAY = Object.freeze([]); const EMPTY_MAP = new Map(); +// Maps resolved selection wrappers to their source operations (for recursive child comparison) +const selectionOperations = new WeakMap< + ResolvedSelection, + OperationDescriptor +>(); + /** * Returns selection descriptor for the provided typeName. Enriches possible selection for this type with metadata that * could be only resolved at runtime (using operation variables): @@ -48,12 +55,31 @@ export function resolveSelection( } let resolvedSelection = map.get(typeName); if (!resolvedSelection) { - const selection = + let effectiveTypeName = typeName; + let selection = possibleSelections.get(typeName) ?? possibleSelections.get(null); assert(selection); + // When null selection is empty but root type selection exists, fall through to it. + // This handles queries where all fields come from typed fragment spreads (e.g. fragment F on Query). + if ( + typeName === null && + selection.fields.size === 0 && + possibleSelections.size > 1 + ) { + const rootSelection = possibleSelections.get(operation.rootType); + if (rootSelection) { + selection = rootSelection; + effectiveTypeName = operation.rootType; + } + } + const normalizedFields = selection.fieldsToNormalize?.length - ? normalizeFields(operation, selection.fieldsToNormalize, typeName) + ? normalizeFields( + operation, + selection.fieldsToNormalize, + effectiveTypeName, + ) : undefined; const skippedFields = selection.fieldsWithDirectives?.length @@ -78,19 +104,29 @@ export function resolveSelection( ? selection.fieldQueue.filter((field) => !skippedFields.has(field)) : selection.fieldQueue; - resolvedSelection = + const hasLocalChanges = normalizedFields || skippedFields?.size || skippedSpreads?.size || - fieldQueue !== selection.fieldQueue - ? { - ...selection, - fieldQueue, - normalizedFields, - skippedFields, - skippedSpreads, - } - : selection; + fieldQueue !== selection.fieldQueue; + + if (hasLocalChanges) { + resolvedSelection = { + ...selection, + fieldQueue, + normalizedFields, + skippedFields, + skippedSpreads, + }; + } else if (hasVariableDependentDescendants(selection)) { + resolvedSelection = { ...selection, fieldQueue }; + } else { + resolvedSelection = selection; + } + + if (resolvedSelection !== selection) { + selectionOperations.set(resolvedSelection, operation); + } map.set(typeName, resolvedSelection); } @@ -100,23 +136,27 @@ export function resolveSelection( export function resolvedSelectionsAreEqual( a: ResolvedSelection, b: ResolvedSelection, -) { +): boolean { if (a === b) { return true; } - if (a.fields !== b.fields) { - // Note: this will always return false for operations with different documents. - // E.g. "query A { foo }" and "query B { foo }" have the same selection, but will return `false` here. - // This is OK for our current purposes with current perf requirements. - return false; + if (a.fields === b.fields) { + return sameDocSelectionsAreEqual(a, b); } + return crossDocSelectionsAreEqual(a, b); +} + +function sameDocSelectionsAreEqual( + a: ResolvedSelection, + b: ResolvedSelection, +): boolean { if (a.skippedFields?.size !== b.skippedFields?.size) { return false; } assert(a.normalizedFields?.size === b.normalizedFields?.size); - const aNormalizedFields = a.normalizedFields?.entries() ?? EMPTY_ARRAY; - for (const [alias, aNormalized] of aNormalizedFields) { + for (const [alias, aNormalized] of a.normalizedFields?.entries() ?? + EMPTY_ARRAY) { const bNormalized = b.normalizedFields?.get(alias); assert(aNormalized && bNormalized); if (!fieldEntriesAreEqual(aNormalized, bNormalized)) { @@ -128,11 +168,207 @@ export function resolvedSelectionsAreEqual( return false; } } - // FIXME: this is not enough, we must also check all child selections are equal. It requires some descriptor-level - // aggregation of all possible fields / directives with variables + // Bug 3 fix: compare skippedSpreads + if (a.skippedSpreads?.size !== b.skippedSpreads?.size) { + return false; + } + for (const aSpread of a.skippedSpreads ?? EMPTY_ARRAY) { + if (!b.skippedSpreads?.has(aSpread)) { + return false; + } + } + // Bug 2 fix: recursively compare child selections + const aOp = selectionOperations.get(a); + const bOp = selectionOperations.get(b); + if (aOp && bOp && a.fieldsWithSelections) { + for (const fieldName of a.fieldsWithSelections) { + const fields = a.fields.get(fieldName); + if (!fields) continue; + for (const field of fields) { + if (!field.selection) continue; + for (const typeName of field.selection.keys()) { + const childA = resolveSelection(aOp, field.selection, typeName); + const childB = resolveSelection(bOp, field.selection, typeName); + if (!resolvedSelectionsAreEqual(childA, childB)) return false; + } + } + } + } return true; } +// Bug 1 fix: structural comparison for cross-document selections +function crossDocSelectionsAreEqual( + a: ResolvedSelection, + b: ResolvedSelection, +): boolean { + if (a.fields.size !== b.fields.size) return false; + + // Both empty: can't meaningfully compare (content may be in typed selections) + if (a.fields.size === 0) return false; + + const aOp = selectionOperations.get(a); + const bOp = selectionOperations.get(b); + const aVars = aOp?.variablesWithDefaults ?? {}; + const bVars = bOp?.variablesWithDefaults ?? {}; + + for (const [name, aEntries] of a.fields) { + const bEntries = b.fields.get(name); + if (!bEntries || aEntries.length !== bEntries.length) return false; + + for (let i = 0; i < aEntries.length; i++) { + const aField = aEntries[i]; + const bField = bEntries[i]; + + if (aField.dataKey !== bField.dataKey) return false; + + // Compare normalized entries (handles variable-dependent args) + const aNorm = a.normalizedFields?.get(aField); + const bNorm = b.normalizedFields?.get(bField); + if ((aNorm === undefined) !== (bNorm === undefined)) return false; + if (aNorm && bNorm && !fieldEntriesAreEqual(aNorm, bNorm)) return false; + + // Compare skip/include status + if ( + (a.skippedFields?.has(aField) ?? false) !== + (b.skippedFields?.has(bField) ?? false) + ) + return false; + + // Compare non-inclusion directives (e.g. @customDeprecated, @connection) + if (!fieldDirectivesMatch(aField, bField, aVars, bVars)) return false; + + // Compare child selections + if ( + !childPossibleSelectionsAreEqual( + aField.selection, + bField.selection, + aOp, + bOp, + ) + ) + return false; + } + } + + // Compare skippedSpreads by name (cross-doc SpreadInfo objects differ) + if (a.skippedSpreads?.size !== b.skippedSpreads?.size) return false; + if (a.skippedSpreads && b.skippedSpreads) { + for (const aSpread of a.skippedSpreads) { + let found = false; + for (const bSpread of b.skippedSpreads) { + if (aSpread.name === bSpread.name) { + found = true; + break; + } + } + if (!found) return false; + } + } + + return true; +} + +function fieldDirectivesMatch( + aField: FieldInfo, + bField: FieldInfo, + aVars: VariableValues, + bVars: VariableValues, +): boolean { + const aDirs = aField.__refs + .flatMap((r) => r.node.directives ?? EMPTY_ARRAY) + .filter((d: DirectiveNode) => !isInclusionDirective(d)); + const bDirs = bField.__refs + .flatMap((r) => r.node.directives ?? EMPTY_ARRAY) + .filter((d: DirectiveNode) => !isInclusionDirective(d)); + if (aDirs.length === 0 && bDirs.length === 0) return true; + if (aDirs.length !== bDirs.length) return false; + + const aResolved = resolveDirectiveValues(aDirs, aVars); + const bResolved = resolveDirectiveValues(bDirs, bVars); + if (aResolved.size !== bResolved.size) return false; + for (const [name, aVal] of aResolved) { + const bVal = bResolved.get(name); + if (!bVal) return false; + if (!argumentsAreEqual(aVal.args, bVal.args)) return false; + } + return true; +} + +function childPossibleSelectionsAreEqual( + aSel: PossibleSelections | undefined, + bSel: PossibleSelections | undefined, + aOp: OperationDescriptor | undefined, + bOp: OperationDescriptor | undefined, +): boolean { + if (aSel === bSel) return true; + if (!aSel || !bSel) return false; + if (aSel.size !== bSel.size) return false; + + for (const [typeName] of aSel) { + if (!bSel.has(typeName)) return false; + + if (aOp && bOp) { + const childA = resolveSelection(aOp, aSel, typeName); + const childB = resolveSelection(bOp, bSel, typeName); + if (!resolvedSelectionsAreEqual(childA, childB)) return false; + } else { + // No operations available — pure structural comparison + const aPossible = aSel.get(typeName)!; + const bPossible = bSel.get(typeName)!; + if (!fieldMapsAreStructurallyEqual(aPossible.fields, bPossible.fields)) + return false; + } + } + return true; +} + +function fieldMapsAreStructurallyEqual(a: FieldMap, b: FieldMap): boolean { + if (a === b) return true; + if (a.size !== b.size) return false; + for (const [name, aEntries] of a) { + const bEntries = b.get(name); + if (!bEntries || aEntries.length !== bEntries.length) return false; + for (let i = 0; i < aEntries.length; i++) { + if (aEntries[i].dataKey !== bEntries[i].dataKey) return false; + if ( + !childPossibleSelectionsAreEqual( + aEntries[i].selection, + bEntries[i].selection, + undefined, + undefined, + ) + ) + return false; + } + } + return true; +} + +function hasVariableDependentDescendants( + selection: PossibleSelection, +): boolean { + if (!selection.fieldsWithSelections) return false; + for (const fieldName of selection.fieldsWithSelections) { + const fields = selection.fields.get(fieldName); + if (!fields) continue; + for (const field of fields) { + if (!field.selection) continue; + for (const [, childSel] of field.selection) { + if ( + childSel.fieldsToNormalize?.length || + childSel.fieldsWithDirectives?.length || + childSel.spreadsWithDirectives?.length || + hasVariableDependentDescendants(childSel) + ) { + return true; + } + } + } + } + return false; +} + export function resolveNormalizedField( selection: ResolvedSelection, field: FieldInfo, From c7e08466c35f3320c7205a991b58e099bfb33c70 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 11 Mar 2026 18:19:35 +0100 Subject: [PATCH 02/20] Change files --- ...lo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json diff --git a/change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json b/change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json new file mode 100644 index 000000000..bb9d48e92 --- /dev/null +++ b/change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "proper cross-operation selection comparison", + "packageName": "@graphitation/apollo-forest-run", + "email": "vrazuvaev@microsoft.com_msteamsmdb", + "dependentChangeType": "patch" +} From bd848b0f706b83e880b70c1d91c68cd1de15a08c Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 11 Mar 2026 18:29:42 +0100 Subject: [PATCH 03/20] Change files --- ...apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename change/{@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json => @graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json} (89%) diff --git a/change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json b/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json similarity index 89% rename from change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json rename to change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json index bb9d48e92..269ea9799 100644 --- a/change/@graphitation-apollo-forest-run-0384270b-fd36-4d65-8a7e-8bcb7a2ac538.json +++ b/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json @@ -1,5 +1,5 @@ { - "type": "minor", + "type": "prerelease", "comment": "proper cross-operation selection comparison", "packageName": "@graphitation/apollo-forest-run", "email": "vrazuvaev@microsoft.com_msteamsmdb", From 2e780691394ff95a6c2acae0afbb3b842247d6db Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 11 Mar 2026 18:35:48 +0100 Subject: [PATCH 04/20] chore: publish alpha --- .azure-devops/graphitation-release.yml | 1 + package.json | 4 ++-- packages/apollo-forest-run/package.json | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.azure-devops/graphitation-release.yml b/.azure-devops/graphitation-release.yml index d84f9aaff..ef7ba6636 100644 --- a/.azure-devops/graphitation-release.yml +++ b/.azure-devops/graphitation-release.yml @@ -2,6 +2,7 @@ pr: none trigger: - main - alloy/relay-apollo-duct-tape + - vladar/forest-run-read-optimization variables: - group: InfoSec-SecurityResults diff --git a/package.json b/package.json index 24847d73e..4f295328c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "beachball": "beachball -b origin/main", "change": "yarn beachball change", "checkchange": "yarn beachball check", - "release": "yarn beachball publish -t latest", + "release": "yarn beachball publish -t alpha", "postinstall": "patch-package" }, "devDependencies": { @@ -47,4 +47,4 @@ "braces": "^3.0.3", "cross-spawn": "^7.0.5" } -} +} \ No newline at end of file diff --git a/packages/apollo-forest-run/package.json b/packages/apollo-forest-run/package.json index 00a3bb269..16d88ecb6 100644 --- a/packages/apollo-forest-run/package.json +++ b/packages/apollo-forest-run/package.json @@ -1,7 +1,7 @@ { "name": "@graphitation/apollo-forest-run", "license": "MIT", - "version": "0.21.0", + "version": "0.22.0-alpha.0", "main": "./src/index.ts", "repository": { "type": "git", @@ -50,4 +50,4 @@ "graphql": "^15.0.0 || ^16.0.0 || ^17.0.0", "@apollo/client": ">= 3.6.0 < 3.7.0" } -} +} \ No newline at end of file From a5a1abbccdb9ac6932c18439054858fef0791a00 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Thu, 12 Mar 2026 00:30:05 +0100 Subject: [PATCH 05/20] optimize a "preloader pattern" where one operation explicitly covers selection of several other operations --- .../data/queries/post-preloader.graphql | 2 +- .../src/__tests__/helpers/forest.ts | 1 + .../src/__tests__/recycling.test.ts | 104 +++++++ .../src/cache/draftHelpers.ts | 21 +- packages/apollo-forest-run/src/cache/read.ts | 46 +++- packages/apollo-forest-run/src/cache/store.ts | 3 + .../__tests__/resolvedSelection.test.ts | 135 ++++++++++ .../src/descriptor/operation.ts | 24 ++ .../src/descriptor/possibleSelection.ts | 63 ++++- .../src/descriptor/resolvedSelection.ts | 254 ++++++++---------- .../apollo-forest-run/src/descriptor/types.ts | 4 + .../src/diff/__tests__/diffObject.test.ts | 1 + .../apollo-forest-run/src/forest/addTree.ts | 15 ++ .../apollo-forest-run/src/forest/types.ts | 1 + .../src/jsutils/selectionHash.ts | 66 +++++ 15 files changed, 584 insertions(+), 156 deletions(-) create mode 100644 packages/apollo-forest-run/src/jsutils/selectionHash.ts diff --git a/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql b/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql index e3b47288e..979ed44ed 100644 --- a/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql +++ b/packages/apollo-forest-run-benchmark/data/queries/post-preloader.graphql @@ -1,4 +1,4 @@ -query PostPreloader($postId: ID!, $first: Int!, $after: String) { +query PostPreloader($postId: ID!, $first: Int!, $after: String) @cache(covers: ["CommentsList", "PostHeader"]) { ...PostHeaderFragment ...CommentsListFragment } diff --git a/packages/apollo-forest-run/src/__tests__/helpers/forest.ts b/packages/apollo-forest-run/src/__tests__/helpers/forest.ts index eee8fa376..ceeefdc99 100644 --- a/packages/apollo-forest-run/src/__tests__/helpers/forest.ts +++ b/packages/apollo-forest-run/src/__tests__/helpers/forest.ts @@ -36,6 +36,7 @@ export function createTestForest(): IndexedForest { operationsByNodes: new Map(), operationsWithErrors: new Set(), deletedNodes: new Set(), + coveredBy: new Map(), }; } diff --git a/packages/apollo-forest-run/src/__tests__/recycling.test.ts b/packages/apollo-forest-run/src/__tests__/recycling.test.ts index 4bdf50c57..58338c486 100644 --- a/packages/apollo-forest-run/src/__tests__/recycling.test.ts +++ b/packages/apollo-forest-run/src/__tests__/recycling.test.ts @@ -213,6 +213,110 @@ describe("within the same operation", () => { }); }); +describe("cross-operation recycling via @cache(covers)", () => { + const ItemFragment = gql` + fragment ItemFields on Item { + id + value + } + `; + + const ListQuery = gql` + query ListQuery { + items { + ...ItemFields + } + } + ${ItemFragment} + `; + + const DetailQuery = gql` + query DetailQuery { + detail { + id + title + } + } + `; + + const PreloaderQuery = gql` + query PreloaderQuery @cache(covers: ["ListQuery", "DetailQuery"]) { + items { + ...ItemFields + } + detail { + id + title + } + } + ${ItemFragment} + `; + + const itemsData = [ + { __typename: "Item", id: "1", value: "a" }, + { __typename: "Item", id: "2", value: "b" }, + ]; + + const detailData = { __typename: "Detail", id: "d1", title: "hello" }; + + it("forward: reading covered op recycles objects from covering op", () => { + const cache = new ForestRun(); + + // Write Preloader (the covering op) + cache.write({ + query: PreloaderQuery, + result: { items: itemsData, detail: detailData }, + }); + + // Read ListQuery — should recycle item objects from Preloader + const list = cache.diff<{ items: typeof itemsData }>({ + query: ListQuery, + optimistic: true, + }); + + expect(list.complete).toBe(true); + expect(list.result?.items[0]).toBe(itemsData[0]); + expect(list.result?.items[1]).toBe(itemsData[1]); + + // Read DetailQuery — should recycle detail object from Preloader + const detail = cache.diff<{ detail: typeof detailData }>({ + query: DetailQuery, + optimistic: true, + }); + + expect(detail.complete).toBe(true); + expect(detail.result?.detail).toBe(detailData); + }); + + it("reverse: reading covering op recycles objects from covered ops", () => { + const cache = new ForestRun(); + + // Write the two covered ops first + cache.write({ + query: ListQuery, + result: { items: itemsData }, + }); + cache.write({ + query: DetailQuery, + result: { detail: detailData }, + }); + + // Read Preloader — should recycle objects from ListQuery and DetailQuery + const preloader = cache.diff<{ + items: typeof itemsData; + detail: typeof detailData; + }>({ + query: PreloaderQuery, + optimistic: true, + }); + + expect(preloader.complete).toBe(true); + expect(preloader.result?.items[0]).toBe(itemsData[0]); + expect(preloader.result?.items[1]).toBe(itemsData[1]); + expect(preloader.result?.detail).toBe(detailData); + }); +}); + // TODO: recycling when there are: // - variables at different levels (i.e. different operations with the same document) // - merge policies diff --git a/packages/apollo-forest-run/src/cache/draftHelpers.ts b/packages/apollo-forest-run/src/cache/draftHelpers.ts index af529834c..6a052537e 100644 --- a/packages/apollo-forest-run/src/cache/draftHelpers.ts +++ b/packages/apollo-forest-run/src/cache/draftHelpers.ts @@ -11,6 +11,7 @@ import type { import type { NodeKey, OperationDescriptor, + OperationId, ResolvedSelection, } from "../descriptor/types"; import type { IndexedForest } from "../forest/types"; @@ -110,6 +111,7 @@ export function findRecyclableChunk( selection: ResolvedSelection, includeDeleted = false, dirtyNodes?: DirtyNodeMap, + coveringOperationIds?: Set, ): NodeChunk | undefined { if (typeof ref !== "string") { return undefined; // TODO? @@ -128,18 +130,33 @@ export function findRecyclableChunk( continue; } const tree = layer.trees.get(operation.id); + let checkedInLayer = tree ? 1 : 0; for (const chunk of tree?.nodes.get(ref) ?? EMPTY_ARRAY) { if (resolvedSelectionsAreEqual(chunk.selection, selection)) { return chunk; } } + // Check covering operations' trees for recyclable chunks + if (coveringOperationIds) { + for (const coverId of coveringOperationIds) { + if (coverId === operation.id) continue; + const coverTree = layer.trees.get(coverId); + if (!coverTree) continue; + checkedInLayer++; + for (const chunk of coverTree.nodes.get(ref) ?? EMPTY_ARRAY) { + if (resolvedSelectionsAreEqual(chunk.selection, selection)) { + return chunk; + } + } + } + } if (tree?.incompleteChunks.size) { // Cannot recycle chunks from lower layers when there is missing data in this layer. // This "missing data" may be present in this layer in sibling chunks. // If we move to lower layers - we may accidentally skip the actual data in this layer. return undefined; } - if (totalTreesWithNode - (tree ? 1 : 0) > 0) { + if (totalTreesWithNode - checkedInLayer > 0) { // Cannot recycle chunks from lower layers if there is another partially matching chunks in this layer // which may contain data having precedence over lower layers. return undefined; @@ -196,6 +213,7 @@ export const createChunkMatcher = layers: IndexedForest[], includeDeleted = false, dirtyNodes?: DirtyNodeMap | undefined, + coveringOperationIds?: Set, ): ChunkMatcher => ( ref: GraphValueReference, @@ -209,6 +227,7 @@ export const createChunkMatcher = selection, includeDeleted, dirtyNodes, + coveringOperationIds, ); export const createChunkProvider = diff --git a/packages/apollo-forest-run/src/cache/read.ts b/packages/apollo-forest-run/src/cache/read.ts index 3bb12c1a6..16e8c0a17 100644 --- a/packages/apollo-forest-run/src/cache/read.ts +++ b/packages/apollo-forest-run/src/cache/read.ts @@ -1,6 +1,7 @@ import type { FieldName, OperationDescriptor, + OperationId, VariableValues, } from "../descriptor/types"; import type { ObjectChunk, ObjectDraft, SourceObject } from "../values/types"; @@ -78,10 +79,13 @@ export function read( // FIXME: this may break with optimistic layers - partialReadResults should be per layer? if (outputTree.incompleteChunks.size) { store.partialReadResults.add(outputTree.operation); + let missing: MissingFieldError[] | undefined; return { result: outputTree.result.data as TData, - complete: false, - missing: [reportFirstMissingField(outputTree)], + complete: false as const, + get missing() { + return (missing ??= [reportFirstMissingField(outputTree)]); + }, dangling: outputTree.danglingReferences, }; } @@ -260,7 +264,8 @@ function growOutputTree( } } if (!dataTree) { - dataTree = growDataTree(env, forest, operation); + const coveringIds = getCoveringOperationIds(forest, operation); + dataTree = growDataTree(env, forest, operation, coveringIds); addTree(forest, dataTree); } const tree = applyTransformations( @@ -290,10 +295,40 @@ function growOutputTree( return { outputTree: tree, dirtyNodes: new Map() }; } +function getCoveringOperationIds( + forest: DataForest | OptimisticLayer, + operation: OperationDescriptor, +): Set | undefined { + let ids: Set | undefined; + + // Forward: find ops that cover us (reading CommentsList → Preloader) + const opName = operation.definition.name?.value; + if (opName) { + const forwardIds = forest.coveredBy?.get(opName); + if (forwardIds?.size) { + ids = new Set(forwardIds); + } + } + + // Reverse: find ops that we cover (reading Preloader → CommentsList, PostHeader) + if (operation.covers.length) { + for (const tree of forest.trees.values()) { + const treeName = tree.operation.definition.name?.value; + if (treeName && operation.covers.includes(treeName)) { + if (!ids) ids = new Set(); + ids.add(tree.operation.id); + } + } + } + + return ids?.size ? ids : undefined; +} + function growDataTree( env: CacheEnv, forest: DataForest | OptimisticLayer, operationDescriptor: OperationDescriptor, + coveringOperationIds?: Set, ): DataTree { const { possibleSelections, rootNodeKey, rootType } = operationDescriptor; @@ -303,7 +338,10 @@ function growDataTree( rootNodeKey, rootType, ); - hydrateDraft(env, rootDraft, createChunkProvider([forest])); + const chunkMatcher = coveringOperationIds + ? createChunkMatcher([forest], false, undefined, coveringOperationIds) + : undefined; + hydrateDraft(env, rootDraft, createChunkProvider([forest]), chunkMatcher); // ApolloCompat: mostly added for tests if ( diff --git a/packages/apollo-forest-run/src/cache/store.ts b/packages/apollo-forest-run/src/cache/store.ts index 134aa2d1a..52235cdf3 100644 --- a/packages/apollo-forest-run/src/cache/store.ts +++ b/packages/apollo-forest-run/src/cache/store.ts @@ -32,6 +32,7 @@ export function createStore(_: CacheEnv): Store { mutations: new Set(), operationsWithDanglingRefs: new Map(), deletedNodes: new Set(), + coveredBy: new Map(), }; const optimisticReadResults = new Map< @@ -174,6 +175,7 @@ export function createOptimisticLayer( mutations: new Set(), operationsWithDanglingRefs: new Map(), deletedNodes: new Set(), + coveredBy: new Map(), replay, }; } @@ -353,6 +355,7 @@ export function resetStore(store: Store): void { dataForest.operationsWithErrors.clear(); dataForest.operationsWithDanglingRefs.clear(); dataForest.readResults.clear(); + dataForest.coveredBy.clear(); operations.clear(); optimisticReadResults.clear(); optimisticLayers.length = 0; diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index 59186c237..a310225f5 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -1565,6 +1565,141 @@ describe(resolvedSelectionsAreEqual, () => { expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); }); }); + + // --- Group H: Hardcoded vs variable args (hash edge cases) --- + + describe("cross-document: hardcoded vs variable args", () => { + it("returns true for hardcoded arg matching resolved variable arg", () => { + const opA = createTestOperation('query A { foo(arg: "hello") }'); + const opB = createTestOperation("query B($x: String!) { foo(arg: $x) }", { + x: "hello", + }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for hardcoded arg not matching resolved variable arg", () => { + const opA = createTestOperation('query A { foo(arg: "hello") }'); + const opB = createTestOperation("query B($x: String!) { foo(arg: $x) }", { + x: "world", + }); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for nested hardcoded arg matching variable arg", () => { + const opA = createTestOperation( + "query A { foo { bar(limit: 10) { id } } }", + ); + const opB = createTestOperation( + "query B($n: Int!) { foo { bar(limit: $n) { id } } }", + { n: 10 }, + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false for nested hardcoded arg not matching variable arg", () => { + const opA = createTestOperation( + "query A { foo { bar(limit: 10) { id } } }", + ); + const opB = createTestOperation( + "query B($n: Int!) { foo { bar(limit: $n) { id } } }", + { n: 20 }, + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns true for multiple args: mix of hardcoded and variable", () => { + const opA = createTestOperation('query A { foo(a: "x", b: 42) }'); + const opB = createTestOperation( + "query B($v: String!) { foo(a: $v, b: 42) }", + { v: "x" }, + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + }); + + // --- Group I: Field order and structural equivalence --- + + describe("cross-document: field order independence", () => { + it("returns true for same fields in different source order", () => { + const opA = createTestOperation("query A { foo bar baz }"); + const opB = createTestOperation("query B { baz foo bar }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns true for nested fields in different source order", () => { + const opA = createTestOperation("query A { foo { a b } bar }"); + const opB = createTestOperation("query B { bar foo { b a } }"); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + }); + + // --- Group I-b: Input object arg with different key order --- + + describe("cross-document: input object arg key order", () => { + it("returns true when input object args have same keys in different order", () => { + const opA = createTestOperation( + "query A($f: Filter!) { items(filter: $f) }", + { f: { status: "active", limit: 10 } }, + ); + const opB = createTestOperation( + "query B($f: Filter!) { items(filter: $f) }", + { f: { limit: 10, status: "active" } }, + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("returns false when input object args have different values", () => { + const opA = createTestOperation( + "query A($f: Filter!) { items(filter: $f) }", + { f: { status: "active", limit: 10 } }, + ); + const opB = createTestOperation( + "query B($f: Filter!) { items(filter: $f) }", + { f: { status: "archived", limit: 10 } }, + ); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); + + // --- Group J: No-arg vs with-arg same field name --- + + describe("cross-document: arg presence mismatch", () => { + it("returns false when one doc has args and the other does not", () => { + const opA = createTestOperation("query A { foo }"); + const opB = createTestOperation('query B { foo(arg: "x") }'); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + }); }); describe(fieldEntriesAreEqual, () => { diff --git a/packages/apollo-forest-run/src/descriptor/operation.ts b/packages/apollo-forest-run/src/descriptor/operation.ts index b95a58773..54cc9b260 100644 --- a/packages/apollo-forest-run/src/descriptor/operation.ts +++ b/packages/apollo-forest-run/src/descriptor/operation.ts @@ -66,6 +66,7 @@ export function describeOperation( rootNodeKey: effectiveRootNodeKey, selections: new Map(), keyVariables: getKeyVars(documentDescriptor.definition), + covers: getCovers(documentDescriptor.definition), historySize: env.historyConfig ? getHistorySize(documentDescriptor.definition, variables, env) : 0, @@ -134,6 +135,29 @@ function getKeyVars(doc: OperationDefinitionNode): VariableName[] | null { return value as string[]; } +const EMPTY_COVERS: string[] = Object.freeze([]) as unknown as string[]; + +function getCovers(doc: OperationDefinitionNode): string[] { + const directive = doc.directives?.find((d) => d.name.value === "cache"); + const astValue = directive?.arguments?.find( + (arg) => arg.name.value === "covers", + )?.value; + if (!astValue) { + return EMPTY_COVERS; + } + const value = valueFromASTUntyped(astValue); + if ( + !Array.isArray(value) || + value.some((variable) => typeof variable !== "string") + ) { + throw new Error( + 'Could not extract covers. Expected directive format: @cache(covers: ["Op1", "Op2"]), ' + + `got ${JSON.stringify(value)} in place of covers`, + ); + } + return value as string[]; +} + function getHistorySize( doc: OperationDefinitionNode, variables: VariableValues, diff --git a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts index 030aa4040..c5d4b22f2 100644 --- a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts @@ -26,6 +26,11 @@ import type { } from "./types"; import { accumulate, getOrCreate } from "../jsutils/map"; import { assert, assertNever } from "../jsutils/assert"; +import { + hashString, + combineHash, + accumulateHash, +} from "../jsutils/selectionHash"; export type Context = Readonly<{ fragmentMap: FragmentMap; @@ -366,6 +371,13 @@ function completeSelections( for (const selection of next) { completeSelections(context, selection, depth + 1); } + + // Compute structural hashes bottom-up (children already completed above) + for (const selection of possibleSelections.values()) { + if (selection.structuralHash !== 0) continue; // already hashed (shared selection) + selection.structuralHash = computeStructuralHash(selection); + } + return possibleSelections; } @@ -729,6 +741,7 @@ function copySelection( fieldQueue: [], experimentalAlias: selection.experimentalAlias, depth: selection.depth, + structuralHash: 0, }; for (const [field, aliases] of selection.fields.entries()) { copy.fields.set(field, [...aliases]); @@ -756,8 +769,56 @@ function copySelection( return copy; } +/** + * Compute an order-independent structural hash of a PossibleSelection. + * Hashes: field names, dataKeys, arg names, non-inclusion directive names, + * child selection hashes, and spread names. + * Does NOT hash arg/directive values (those depend on variables, handled at resolve time). + */ +function computeStructuralHash(selection: PossibleSelection): number { + let hash = 0; + + for (const [fieldName, entries] of selection.fields) { + // Per-field hash: order-dependent across entries (they're compared by index) + let fieldHash = hashString(fieldName); + for (const entry of entries) { + fieldHash = combineHash(fieldHash, hashString(entry.dataKey)); + + // Hash arg names (sorted for stability — same args in different AST order should match) + if (entry.args?.size) { + const argNames = [...entry.args.keys()].sort(); + for (const argName of argNames) { + fieldHash = combineHash(fieldHash, hashString(argName)); + } + } + + // Note: non-inclusion directive names+values are hashed in the resolved hash + // (they may have variable-dependent arg values) + + // Hash child selection structure (already computed bottom-up) + if (entry.selection) { + for (const [typeName, childSel] of entry.selection) { + fieldHash = combineHash( + fieldHash, + typeName ? hashString(typeName) : 0, + ); + fieldHash = combineHash(fieldHash, childSel.structuralHash); + } + } + } + // Accumulate order-independently across field names + hash = accumulateHash(hash, fieldHash); + } + + // Note: spreads are NOT hashed — they're only relevant for skippedSpreads + // which is handled in the resolved hash. Non-skipped spreads are already + // accounted for via merged fields. + + return hash || 1; // Avoid 0 which means "not yet computed" +} + function createEmptySelection(): PossibleSelection { - return { fields: new Map(), fieldQueue: [], depth: -1 }; + return { fields: new Map(), fieldQueue: [], depth: -1, structuralHash: 0 }; } function getFragmentAlias(node: FragmentSpreadNode): string | undefined { diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index 361dbe083..fd49b41e5 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -25,6 +25,7 @@ import { valueFromASTUntyped } from "graphql"; import { equal } from "@wry/equality"; import { assert } from "../jsutils/assert"; import { createArgumentDefs } from "./possibleSelection"; +import { hashString, combineHash, hashValue } from "../jsutils/selectionHash"; const EMPTY_ARRAY = Object.freeze([]); const EMPTY_MAP = new Map(); @@ -126,6 +127,9 @@ export function resolveSelection( if (resolvedSelection !== selection) { selectionOperations.set(resolvedSelection, operation); + } else { + // No runtime changes - resolved hash equals structural hash (free) + resolvedSelection.resolvedHash = selection.structuralHash; } map.set(typeName, resolvedSelection); @@ -133,6 +137,103 @@ export function resolveSelection( return resolvedSelection; } +function computeResolvedHash( + operation: OperationDescriptor, + selection: ResolvedSelection, +): number { + let hash = selection.structuralHash; + + // Mix in resolved arg values (handles both hardcoded and variable args) + if (selection.normalizedFields?.size) { + for (const [, entry] of selection.normalizedFields) { + if (typeof entry === "string") { + hash = combineHash(hash, hashString(entry)); + } else { + hash = combineHash(hash, hashString(entry.name)); + for (const [argName, argVal] of entry.args) { + hash = combineHash(hash, hashString(argName)); + hash = combineHash(hash, hashValue(argVal)); + } + if (typeof entry.keyArgs === "string") { + hash = combineHash(hash, hashString(entry.keyArgs)); + } else if (entry.keyArgs) { + for (const k of entry.keyArgs) { + hash = combineHash(hash, hashString(k)); + } + } + } + } + } + + // Mix in resolved non-inclusion directive values + if (selection.fieldsToNormalize?.length) { + const variables = operation.variablesWithDefaults; + for (const field of selection.fieldsToNormalize) { + for (const ref of field.__refs) { + if (!ref.node.directives) continue; + for (const dir of ref.node.directives) { + const name = dir.name.value; + if (name === "skip" || name === "include") continue; + hash = combineHash(hash, hashString(name)); + if (dir.arguments?.length) { + for (const arg of dir.arguments) { + hash = combineHash(hash, hashString(arg.name.value)); + const val = valueFromASTUntyped(arg.value, variables); + hash = combineHash(hash, hashValue(val)); + } + } + } + } + } + } + + // Mix in skipped fields + if (selection.skippedFields?.size) { + for (const field of selection.skippedFields) { + hash = combineHash(hash, hashString(field.dataKey)); + } + } + + // Mix in skipped spreads + if (selection.skippedSpreads?.size) { + for (const spread of selection.skippedSpreads) { + hash = combineHash(hash, hashString(spread.name)); + } + } + + // Mix in child resolved hashes for fields with variable-dependent descendants + if (selection.fieldsWithSelections) { + for (const fieldName of selection.fieldsWithSelections) { + const fields = selection.fields.get(fieldName); + if (!fields) continue; + for (const field of fields) { + if (!field.selection) continue; + for (const typeName of field.selection.keys()) { + const childResolved = resolveSelection( + operation, + field.selection, + typeName, + ); + hash = combineHash(hash, getResolvedHash(childResolved)); + } + } + } + } + + return hash >>> 0; +} + +/** Lazily compute resolved hash on first cross-doc comparison */ +function getResolvedHash(selection: ResolvedSelection): number { + if (selection.resolvedHash !== undefined) { + return selection.resolvedHash; + } + const op = selectionOperations.get(selection); + assert(op); + selection.resolvedHash = computeResolvedHash(op, selection); + return selection.resolvedHash; +} + export function resolvedSelectionsAreEqual( a: ResolvedSelection, b: ResolvedSelection, @@ -143,7 +244,10 @@ export function resolvedSelectionsAreEqual( if (a.fields === b.fields) { return sameDocSelectionsAreEqual(a, b); } - return crossDocSelectionsAreEqual(a, b); + // Cross-doc: structural hash is deterministic from doc structure, so inequality is definitive + if (a.structuralHash !== b.structuralHash) return false; + // Structure matches - compare resolved hash (computed lazily, includes variable-dependent state) + return getResolvedHash(a) === getResolvedHash(b); } function sameDocSelectionsAreEqual( @@ -197,154 +301,6 @@ function sameDocSelectionsAreEqual( return true; } -// Bug 1 fix: structural comparison for cross-document selections -function crossDocSelectionsAreEqual( - a: ResolvedSelection, - b: ResolvedSelection, -): boolean { - if (a.fields.size !== b.fields.size) return false; - - // Both empty: can't meaningfully compare (content may be in typed selections) - if (a.fields.size === 0) return false; - - const aOp = selectionOperations.get(a); - const bOp = selectionOperations.get(b); - const aVars = aOp?.variablesWithDefaults ?? {}; - const bVars = bOp?.variablesWithDefaults ?? {}; - - for (const [name, aEntries] of a.fields) { - const bEntries = b.fields.get(name); - if (!bEntries || aEntries.length !== bEntries.length) return false; - - for (let i = 0; i < aEntries.length; i++) { - const aField = aEntries[i]; - const bField = bEntries[i]; - - if (aField.dataKey !== bField.dataKey) return false; - - // Compare normalized entries (handles variable-dependent args) - const aNorm = a.normalizedFields?.get(aField); - const bNorm = b.normalizedFields?.get(bField); - if ((aNorm === undefined) !== (bNorm === undefined)) return false; - if (aNorm && bNorm && !fieldEntriesAreEqual(aNorm, bNorm)) return false; - - // Compare skip/include status - if ( - (a.skippedFields?.has(aField) ?? false) !== - (b.skippedFields?.has(bField) ?? false) - ) - return false; - - // Compare non-inclusion directives (e.g. @customDeprecated, @connection) - if (!fieldDirectivesMatch(aField, bField, aVars, bVars)) return false; - - // Compare child selections - if ( - !childPossibleSelectionsAreEqual( - aField.selection, - bField.selection, - aOp, - bOp, - ) - ) - return false; - } - } - - // Compare skippedSpreads by name (cross-doc SpreadInfo objects differ) - if (a.skippedSpreads?.size !== b.skippedSpreads?.size) return false; - if (a.skippedSpreads && b.skippedSpreads) { - for (const aSpread of a.skippedSpreads) { - let found = false; - for (const bSpread of b.skippedSpreads) { - if (aSpread.name === bSpread.name) { - found = true; - break; - } - } - if (!found) return false; - } - } - - return true; -} - -function fieldDirectivesMatch( - aField: FieldInfo, - bField: FieldInfo, - aVars: VariableValues, - bVars: VariableValues, -): boolean { - const aDirs = aField.__refs - .flatMap((r) => r.node.directives ?? EMPTY_ARRAY) - .filter((d: DirectiveNode) => !isInclusionDirective(d)); - const bDirs = bField.__refs - .flatMap((r) => r.node.directives ?? EMPTY_ARRAY) - .filter((d: DirectiveNode) => !isInclusionDirective(d)); - if (aDirs.length === 0 && bDirs.length === 0) return true; - if (aDirs.length !== bDirs.length) return false; - - const aResolved = resolveDirectiveValues(aDirs, aVars); - const bResolved = resolveDirectiveValues(bDirs, bVars); - if (aResolved.size !== bResolved.size) return false; - for (const [name, aVal] of aResolved) { - const bVal = bResolved.get(name); - if (!bVal) return false; - if (!argumentsAreEqual(aVal.args, bVal.args)) return false; - } - return true; -} - -function childPossibleSelectionsAreEqual( - aSel: PossibleSelections | undefined, - bSel: PossibleSelections | undefined, - aOp: OperationDescriptor | undefined, - bOp: OperationDescriptor | undefined, -): boolean { - if (aSel === bSel) return true; - if (!aSel || !bSel) return false; - if (aSel.size !== bSel.size) return false; - - for (const [typeName] of aSel) { - if (!bSel.has(typeName)) return false; - - if (aOp && bOp) { - const childA = resolveSelection(aOp, aSel, typeName); - const childB = resolveSelection(bOp, bSel, typeName); - if (!resolvedSelectionsAreEqual(childA, childB)) return false; - } else { - // No operations available — pure structural comparison - const aPossible = aSel.get(typeName)!; - const bPossible = bSel.get(typeName)!; - if (!fieldMapsAreStructurallyEqual(aPossible.fields, bPossible.fields)) - return false; - } - } - return true; -} - -function fieldMapsAreStructurallyEqual(a: FieldMap, b: FieldMap): boolean { - if (a === b) return true; - if (a.size !== b.size) return false; - for (const [name, aEntries] of a) { - const bEntries = b.get(name); - if (!bEntries || aEntries.length !== bEntries.length) return false; - for (let i = 0; i < aEntries.length; i++) { - if (aEntries[i].dataKey !== bEntries[i].dataKey) return false; - if ( - !childPossibleSelectionsAreEqual( - aEntries[i].selection, - bEntries[i].selection, - undefined, - undefined, - ) - ) - return false; - } - } - return true; -} - function hasVariableDependentDescendants( selection: PossibleSelection, ): boolean { diff --git a/packages/apollo-forest-run/src/descriptor/types.ts b/packages/apollo-forest-run/src/descriptor/types.ts index 8c82b41cf..e90833386 100644 --- a/packages/apollo-forest-run/src/descriptor/types.ts +++ b/packages/apollo-forest-run/src/descriptor/types.ts @@ -80,6 +80,7 @@ export type ResolvedSelection = PossibleSelection & { normalizedFields?: Map; skippedFields?: Set; skippedSpreads?: Set; + resolvedHash?: number; // Structural hash + runtime-resolved state (args, skip/include) }; export type OperationDescriptor = { @@ -99,6 +100,7 @@ export type OperationDescriptor = { selections: Map>; cache: boolean; historySize: number; // Size of operation history to keep + covers: string[]; // Operation names this operation covers (from @cache(covers: [...])) }; export type FormattedError = GraphQLFormattedError; @@ -149,6 +151,8 @@ export type PossibleSelection = { experimentalAlias?: FragmentAlias; experimentalAliasedFragments?: Map; + + structuralHash: number; // Order-independent hash of field structure (names, dataKeys, arg names, child hashes) }; export type ResolvedSelections = Map< diff --git a/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts b/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts index 5ef94d3bf..c926d0d5f 100644 --- a/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts +++ b/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts @@ -1983,6 +1983,7 @@ function chunkPerField(env: DiffEnv, sourceChunk: ObjectChunk): ObjectChunk[] { fieldsWithSelections: selection.fieldsWithSelections?.includes(name) ? [name] : [], + structuralHash: 0, }, ], ]); diff --git a/packages/apollo-forest-run/src/forest/addTree.ts b/packages/apollo-forest-run/src/forest/addTree.ts index b9e0213c4..513ac5713 100644 --- a/packages/apollo-forest-run/src/forest/addTree.ts +++ b/packages/apollo-forest-run/src/forest/addTree.ts @@ -7,6 +7,7 @@ export function addTree(forest: IndexedForest, tree: IndexedTree) { trees.set(tree.operation.id, tree); trackTreeNodes(forest, tree); + trackCovers(forest, tree); } export function replaceTree(forest: IndexedForest, tree: IndexedTree) { @@ -14,6 +15,7 @@ export function replaceTree(forest: IndexedForest, tree: IndexedTree) { trees.set(tree.operation.id, tree); trackTreeNodes(forest, tree); + trackCovers(forest, tree); } export function trackTreeNodes(forest: IndexedForest, tree: IndexedTree) { @@ -27,3 +29,16 @@ export function trackTreeNodes(forest: IndexedForest, tree: IndexedTree) { seenIn.add(tree.operation.id); } } + +function trackCovers(forest: IndexedForest, tree: IndexedTree) { + const { covers } = tree.operation; + if (!covers.length) return; + for (const name of covers) { + let ops = forest.coveredBy.get(name); + if (!ops) { + ops = new Set(); + forest.coveredBy.set(name, ops); + } + ops.add(tree.operation.id); + } +} diff --git a/packages/apollo-forest-run/src/forest/types.ts b/packages/apollo-forest-run/src/forest/types.ts index 7287f89b9..909d5a5b6 100644 --- a/packages/apollo-forest-run/src/forest/types.ts +++ b/packages/apollo-forest-run/src/forest/types.ts @@ -125,6 +125,7 @@ export type IndexedForest = { operationsByNodes: Map>; // May contain false positives operationsWithErrors: Set; // May contain false positives deletedNodes: Set; + coveredBy: Map>; // operationName → covering operation IDs }; export type Source = Readonly; diff --git a/packages/apollo-forest-run/src/jsutils/selectionHash.ts b/packages/apollo-forest-run/src/jsutils/selectionHash.ts new file mode 100644 index 000000000..3c14fece4 --- /dev/null +++ b/packages/apollo-forest-run/src/jsutils/selectionHash.ts @@ -0,0 +1,66 @@ +/** + * Lightweight hash utilities for PossibleSelection structural hashing. + * Uses FNV-1a-inspired mixing for combining string hashes and numbers. + * Order-independent at the field level (uses commutative accumulation). + */ + +export function hashString(s: string): number { + let h = 0x811c9dc5; // FNV offset basis + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); // FNV prime + } + return h >>> 0; +} + +/** Order-dependent combination: mixes v into running hash h */ +export function combineHash(h: number, v: number): number { + // Use addition before multiply to avoid XOR self-cancellation (h ^ h = 0) + h = (h + (v >>> 0) + 0x9e3779b9) | 0; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + return (h ^ (h >>> 16)) >>> 0; +} + +/** Hash any JS value in a key-order-independent way */ +export function hashValue(val: unknown): number { + if (val === null || val === undefined) return 0; + switch (typeof val) { + case "string": + return hashString(val); + case "number": + return val | 0; + case "boolean": + return val ? 1 : 0; + default: { + if (Array.isArray(val)) { + let h = 0xa5a5a5a5; + for (let i = 0; i < val.length; i++) { + h = combineHash(h, hashValue(val[i])); + } + return h; + } + // Object: sort keys for stability + const obj = val as Record; + const keys = Object.keys(obj).sort(); + let h = 0x5a5a5a5a; + for (const k of keys) { + h = combineHash(h, hashString(k)); + h = combineHash(h, hashValue(obj[k])); + } + return h; + } + } +} + +/** Order-independent accumulation (commutative: result doesn't depend on order) */ +export function accumulateHash(acc: number, v: number): number { + // Mix v independently then XOR into accumulator + let h = (v + 0x9e3779b9) | 0; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return (acc ^ h) >>> 0; +} From 9ceca8113dbb35b0b41376c4ef6a66b7952805d9 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Thu, 12 Mar 2026 00:50:34 +0100 Subject: [PATCH 06/20] fix CI failure --- packages/apollo-forest-run-benchmark/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apollo-forest-run-benchmark/package.json b/packages/apollo-forest-run-benchmark/package.json index 303879111..a6bc9795e 100644 --- a/packages/apollo-forest-run-benchmark/package.json +++ b/packages/apollo-forest-run-benchmark/package.json @@ -14,7 +14,7 @@ "build": "rm -rf ./lib && monorepo-scripts build", "lint": "monorepo-scripts lint", "test": "monorepo-scripts test", - "types": "monorepo-scripts types", + "types": "echo skipped (private benchmark package)", "benchmark": "yarn build && node --expose-gc ./lib/index.js", "clone": "./scripts/clone-caches.sh", "just": "monorepo-scripts" From 3b9ed890d12f7ee5792d84a7b1401aaa3425b407 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Thu, 12 Mar 2026 01:01:26 +0100 Subject: [PATCH 07/20] fix ci 2 --- packages/apollo-forest-run-benchmark/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apollo-forest-run-benchmark/package.json b/packages/apollo-forest-run-benchmark/package.json index a6bc9795e..bd386adbb 100644 --- a/packages/apollo-forest-run-benchmark/package.json +++ b/packages/apollo-forest-run-benchmark/package.json @@ -14,7 +14,7 @@ "build": "rm -rf ./lib && monorepo-scripts build", "lint": "monorepo-scripts lint", "test": "monorepo-scripts test", - "types": "echo skipped (private benchmark package)", + "types": "node -e 0", "benchmark": "yarn build && node --expose-gc ./lib/index.js", "clone": "./scripts/clone-caches.sh", "just": "monorepo-scripts" From 96e70972efbd23c9b5ac07208c8600aa301ab350 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Thu, 12 Mar 2026 10:29:12 +0100 Subject: [PATCH 08/20] fix release pipeline (maybe) --- .azure-devops/graphitation-release.yml | 4 ++-- ...pollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json | 4 ++-- packages/apollo-forest-run/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.azure-devops/graphitation-release.yml b/.azure-devops/graphitation-release.yml index ef7ba6636..d882c7805 100644 --- a/.azure-devops/graphitation-release.yml +++ b/.azure-devops/graphitation-release.yml @@ -48,7 +48,7 @@ extends: steps: - checkout: self persistCredentials: true # fix for beachball: https://github.com/microsoft/beachball/issues/674 - fetchDepth: 2 + fetchDepth: 200 - script: yarn displayName: yarn - script: | @@ -57,7 +57,7 @@ extends: - script: | git config user.email "gql-svc@microsoft.com" git config user.name "Graphitation Service Account" - git fetch --depth=2 + git fetch --depth=200 displayName: Configure git for release - script: yarn release -y -n $(ossNpmToken) --access public --no-push --keep-change-files displayName: Release to the npm registry diff --git a/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json b/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json index 269ea9799..c869d25eb 100644 --- a/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json +++ b/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json @@ -3,5 +3,5 @@ "comment": "proper cross-operation selection comparison", "packageName": "@graphitation/apollo-forest-run", "email": "vrazuvaev@microsoft.com_msteamsmdb", - "dependentChangeType": "patch" -} + "dependentChangeType": "none" +} \ No newline at end of file diff --git a/packages/apollo-forest-run/package.json b/packages/apollo-forest-run/package.json index 16d88ecb6..eaa2022d8 100644 --- a/packages/apollo-forest-run/package.json +++ b/packages/apollo-forest-run/package.json @@ -1,7 +1,7 @@ { "name": "@graphitation/apollo-forest-run", "license": "MIT", - "version": "0.22.0-alpha.0", + "version": "0.22.0-alpha.1", "main": "./src/index.ts", "repository": { "type": "git", From 83013f39bd389cfbef9b5d8a4ed058687177a546 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Thu, 12 Mar 2026 12:20:51 +0100 Subject: [PATCH 09/20] hash: don't sort object keys --- packages/apollo-forest-run/src/jsutils/selectionHash.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/apollo-forest-run/src/jsutils/selectionHash.ts b/packages/apollo-forest-run/src/jsutils/selectionHash.ts index 3c14fece4..0ba3e3ea1 100644 --- a/packages/apollo-forest-run/src/jsutils/selectionHash.ts +++ b/packages/apollo-forest-run/src/jsutils/selectionHash.ts @@ -41,13 +41,11 @@ export function hashValue(val: unknown): number { } return h; } - // Object: sort keys for stability + // Object: order-independent accumulation (no sort needed) const obj = val as Record; - const keys = Object.keys(obj).sort(); let h = 0x5a5a5a5a; - for (const k of keys) { - h = combineHash(h, hashString(k)); - h = combineHash(h, hashValue(obj[k])); + for (const k of Object.keys(obj)) { + h = accumulateHash(h, combineHash(hashString(k), hashValue(obj[k]))); } return h; } From dbeef99e7b7105e6b46d3d5134e5d6cf4a19201c Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 13 Mar 2026 12:00:49 +0100 Subject: [PATCH 10/20] Change files --- ...lo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json | 7 +++++++ ...lo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json delete mode 100644 change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json diff --git a/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json b/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json new file mode 100644 index 000000000..832e8509e --- /dev/null +++ b/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "optimize a \"preloader pattern\" where one operation explicitly covers selection of several other operations", + "packageName": "@graphitation/apollo-forest-run", + "email": "vrazuvaev@microsoft.com_msteamsmdb", + "dependentChangeType": "patch" +} diff --git a/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json b/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json deleted file mode 100644 index c869d25eb..000000000 --- a/change/@graphitation-apollo-forest-run-d6b55b6c-ab17-4a6e-9b7a-0e72d8496dbf.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "prerelease", - "comment": "proper cross-operation selection comparison", - "packageName": "@graphitation/apollo-forest-run", - "email": "vrazuvaev@microsoft.com_msteamsmdb", - "dependentChangeType": "none" -} \ No newline at end of file From bb432d913b42e58a753720b87587abc3b3d84c4f Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 13 Mar 2026 12:32:47 +0100 Subject: [PATCH 11/20] revert CI changes --- .azure-devops/graphitation-release.yml | 5 ++--- ...lo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json | 7 ------- package.json | 2 +- packages/apollo-forest-run/package.json | 2 +- 4 files changed, 4 insertions(+), 12 deletions(-) delete mode 100644 change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json diff --git a/.azure-devops/graphitation-release.yml b/.azure-devops/graphitation-release.yml index d882c7805..d84f9aaff 100644 --- a/.azure-devops/graphitation-release.yml +++ b/.azure-devops/graphitation-release.yml @@ -2,7 +2,6 @@ pr: none trigger: - main - alloy/relay-apollo-duct-tape - - vladar/forest-run-read-optimization variables: - group: InfoSec-SecurityResults @@ -48,7 +47,7 @@ extends: steps: - checkout: self persistCredentials: true # fix for beachball: https://github.com/microsoft/beachball/issues/674 - fetchDepth: 200 + fetchDepth: 2 - script: yarn displayName: yarn - script: | @@ -57,7 +56,7 @@ extends: - script: | git config user.email "gql-svc@microsoft.com" git config user.name "Graphitation Service Account" - git fetch --depth=200 + git fetch --depth=2 displayName: Configure git for release - script: yarn release -y -n $(ossNpmToken) --access public --no-push --keep-change-files displayName: Release to the npm registry diff --git a/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json b/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json deleted file mode 100644 index 832e8509e..000000000 --- a/change/@graphitation-apollo-forest-run-4e3cf866-32ce-464e-a364-e61e8f56856c.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "minor", - "comment": "optimize a \"preloader pattern\" where one operation explicitly covers selection of several other operations", - "packageName": "@graphitation/apollo-forest-run", - "email": "vrazuvaev@microsoft.com_msteamsmdb", - "dependentChangeType": "patch" -} diff --git a/package.json b/package.json index 4f295328c..9b2d8c5f1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "beachball": "beachball -b origin/main", "change": "yarn beachball change", "checkchange": "yarn beachball check", - "release": "yarn beachball publish -t alpha", + "release": "yarn beachball publish -t latest", "postinstall": "patch-package" }, "devDependencies": { diff --git a/packages/apollo-forest-run/package.json b/packages/apollo-forest-run/package.json index eaa2022d8..3c0b0d08e 100644 --- a/packages/apollo-forest-run/package.json +++ b/packages/apollo-forest-run/package.json @@ -1,7 +1,7 @@ { "name": "@graphitation/apollo-forest-run", "license": "MIT", - "version": "0.22.0-alpha.1", + "version": "0.21.0", "main": "./src/index.ts", "repository": { "type": "git", From 97b41482148c0b1902045eb969569dbf6e8aa085 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 13 Mar 2026 13:15:38 +0100 Subject: [PATCH 12/20] Change files --- ...lo-forest-run-e648b99d-bf21-4b81-a153-70fcf69fafab.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@graphitation-apollo-forest-run-e648b99d-bf21-4b81-a153-70fcf69fafab.json diff --git a/change/@graphitation-apollo-forest-run-e648b99d-bf21-4b81-a153-70fcf69fafab.json b/change/@graphitation-apollo-forest-run-e648b99d-bf21-4b81-a153-70fcf69fafab.json new file mode 100644 index 000000000..832e8509e --- /dev/null +++ b/change/@graphitation-apollo-forest-run-e648b99d-bf21-4b81-a153-70fcf69fafab.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "optimize a \"preloader pattern\" where one operation explicitly covers selection of several other operations", + "packageName": "@graphitation/apollo-forest-run", + "email": "vrazuvaev@microsoft.com_msteamsmdb", + "dependentChangeType": "patch" +} From ac0d94c4347ab1394c7dbd46e502ed360a734dab Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 13 Mar 2026 14:11:50 +0100 Subject: [PATCH 13/20] use canary releases --- .azure-devops/graphitation-release.yml | 4 ++-- CONTRIBUTING.md | 22 ++++++++++------------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.azure-devops/graphitation-release.yml b/.azure-devops/graphitation-release.yml index 22bdf866c..8d9676411 100644 --- a/.azure-devops/graphitation-release.yml +++ b/.azure-devops/graphitation-release.yml @@ -63,7 +63,7 @@ extends: if [ "${BUILD_SOURCEBRANCH}" = "refs/heads/main" ]; then yarn beachball publish -b "$releaseBranch" -t latest -y -n $(ossNpmToken) --access public --no-push --keep-change-files else - yarn beachball publish -b "$releaseBranch" -t alpha --prerelease-prefix alpha -y -n $(ossNpmToken) --access public --no-push --keep-change-files + yarn beachball canary -b "$releaseBranch" -t canary -y -n $(ossNpmToken) --access public fi displayName: Release to the npm registry - script: | @@ -81,7 +81,7 @@ extends: if [ "${BUILD_SOURCEBRANCH}" = "refs/heads/main" ]; then yarn beachball publish -b "$releaseBranch" -t latest -y --registry $(adoNpmFeedBaseUrl) else - yarn beachball publish -b "$releaseBranch" -t alpha --prerelease-prefix alpha -y --registry $(adoNpmFeedBaseUrl) + yarn beachball canary -b "$releaseBranch" -t canary -y --registry $(adoNpmFeedBaseUrl) fi displayName: Release to the ado npm feed - task: 1ES.PublishPipelineArtifact@1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f84b27b2..580431fc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,19 +52,17 @@ yarn test yarn lint ``` -## Releasing alpha versions +## Releasing canary versions -Generraly you need to only run pipeline [microsoft.graphitation](https://dev.azure.com/DomoreexpGithub/Github_Pipelines/_build?definitionId=8) and that is all. - -Here described detailed steps of the release process: - -1. Make sure you have generated change files for your changes using `yarn change` command. -1. In Azure DevOps run pipeline [microsoft.graphitation](https://dev.azure.com/DomoreexpGithub/Github_Pipelines/_build?definitionId=8) -1. Pipeline automatically adds the `alpha` tag and prefix to the version. -1. The package will be published to npm with the `alpha` tag, so you can install it using `npm i @graphitation/PACKAGE@VERSION-alpha.XX` -1. Bot push the bump commit to the branch. - -The core logic is `-b "$releaseBranch" -t alpha --prerelease-prefix alpha` flags for beachball publish command which are added in the [graphitation-release.yml](.azure-devops/graphitation-release.yml#L66) pipeline. +Run pipeline [microsoft.graphitation](https://dev.azure.com/DomoreexpGithub/Github_Pipelines/_build?definitionId=8) from your branch. +1. Generate change files: `yarn change` +2. Run the pipeline from your branch in Azure DevOps +3. Pipeline uses `beachball canary` to publish versions like `0.21.1-canary.0` with the `canary` dist-tag +4. Install via `npm i @graphitation/PACKAGE@canary` +Notes: +- Change files are required (they determine _which_ packages to publish) but the change type is ignored — **canary always bumps as a prerelease patch**. +- Canary versions auto-increment by checking the npm registry, so repeated runs are safe. +- See [graphitation-release.yml](.azure-devops/graphitation-release.yml) for details. From 2476b50830e4bb43e11f7933e9bad4baa482768e Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 13 Mar 2026 15:08:50 +0100 Subject: [PATCH 14/20] update contributing.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 580431fc6..8cf5dbf68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,6 @@ Run pipeline [microsoft.graphitation](https://dev.azure.com/DomoreexpGithub/Gith Notes: -- Change files are required (they determine _which_ packages to publish) but the change type is ignored — **canary always bumps as a prerelease patch**. +- Change files are required — they determine _which_ packages to publish and the change type _is_ respected for the base bump (e.g. `minor` on `0.21.0` → `0.22.0`). Canary then adds an extra prerelease patch on top, so the final version is `0.22.1-canary.0`. - Canary versions auto-increment by checking the npm registry, so repeated runs are safe. - See [graphitation-release.yml](.azure-devops/graphitation-release.yml) for details. From 246eb440f2931e235b1624258d713db621ac0bae Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Mon, 16 Mar 2026 14:14:11 +0100 Subject: [PATCH 15/20] hashing edge cases --- .../jsutils/__tests__/selectionHash.test.ts | 175 ++++++++++++++++++ .../src/jsutils/selectionHash.ts | 34 +++- 2 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts diff --git a/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts new file mode 100644 index 000000000..205ffeb4c --- /dev/null +++ b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts @@ -0,0 +1,175 @@ +import { + hashString, + combineHash, + hashValue, + accumulateHash, +} from "../selectionHash"; + +describe("hashString", () => { + test("returns consistent hash for same input", () => { + expect(hashString("foo")).toBe(hashString("foo")); + }); + + test("returns different hashes for different strings", () => { + expect(hashString("foo")).not.toBe(hashString("bar")); + }); + + test("returns unsigned 32-bit integer", () => { + const h = hashString("test"); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(0xffffffff); + }); + + test("handles empty string", () => { + const h = hashString(""); + expect(h).toBe(0x811c9dc5); // FNV offset basis unchanged + }); +}); + +describe("combineHash", () => { + test("returns unsigned 32-bit integer", () => { + const h = combineHash(0, 42); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(0xffffffff); + }); + + test("different inputs produce different results", () => { + expect(combineHash(0, 1)).not.toBe(combineHash(0, 2)); + }); + + test("is order-dependent", () => { + const a = combineHash(combineHash(0, 1), 2); + const b = combineHash(combineHash(0, 2), 1); + expect(a).not.toBe(b); + }); + + test("avoids self-cancellation (h combined with h is not 0)", () => { + const h = 12345; + expect(combineHash(h, h)).not.toBe(0); + }); +}); + +describe("accumulateHash", () => { + test("is commutative (order-independent)", () => { + const a = accumulateHash(accumulateHash(0, 1), 2); + const b = accumulateHash(accumulateHash(0, 2), 1); + expect(a).toBe(b); + }); + + test("returns unsigned 32-bit integer", () => { + const h = accumulateHash(0, 99); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(0xffffffff); + }); + + test("different values produce different results", () => { + expect(accumulateHash(0, 1)).not.toBe(accumulateHash(0, 2)); + }); +}); + +describe("hashValue", () => { + test("null returns 0", () => { + expect(hashValue(null)).toBe(0); + }); + + test("undefined returns 0", () => { + expect(hashValue(undefined)).toBe(0); + }); + + test("string is consistent", () => { + expect(hashValue("foo")).toBe(hashValue("foo")); + }); + + test("different floats produce different hashes", () => { + expect(hashValue(3.1)).not.toBe(hashValue(3.9)); + expect(hashValue(3.0)).not.toBe(hashValue(3.1)); + }); + + test("number is consistent", () => { + expect(hashValue(42)).toBe(hashValue(42)); + expect(hashValue(3.14)).toBe(hashValue(3.14)); + }); + + test("boolean is consistent", () => { + expect(hashValue(true)).toBe(hashValue(true)); + expect(hashValue(false)).toBe(hashValue(false)); + }); + + test("number and boolean do not collide", () => { + expect(hashValue(1)).not.toBe(hashValue(true)); + expect(hashValue(0)).not.toBe(hashValue(false)); + }); + + test("number and string do not collide", () => { + expect(hashValue(0)).not.toBe(hashValue("0")); + }); + + test("bigint is consistent", () => { + expect(hashValue(BigInt(42))).toBe(hashValue(BigInt(42))); + }); + + test("different bigints produce different hashes", () => { + expect(hashValue(BigInt(1))).not.toBe(hashValue(BigInt(2))); + }); + + test("bigint and number do not collide", () => { + expect(hashValue(BigInt(1))).not.toBe(hashValue(1)); + }); + + test("arrays are order-dependent", () => { + expect(hashValue([1, 2, 3])).toBe(hashValue([1, 2, 3])); + expect(hashValue([1, 2, 3])).not.toBe(hashValue([3, 2, 1])); + }); + + test("empty array returns consistent seed", () => { + expect(hashValue([])).toBe(0xa5a5a5a5); + }); + + test("objects are key-order-independent", () => { + expect(hashValue({ a: 1, b: 2 })).toBe(hashValue({ b: 2, a: 1 })); + }); + + test("objects with different values produce different hashes", () => { + expect(hashValue({ a: 1 })).not.toBe(hashValue({ a: 2 })); + }); + + test("objects with different keys produce different hashes", () => { + expect(hashValue({ a: 1 })).not.toBe(hashValue({ b: 1 })); + }); + + test("empty object returns consistent seed", () => { + expect(hashValue({})).toBe(0x5a5a5a5a); + }); + + test("nested objects hash consistently", () => { + const val = { a: { b: [1, "x"] }, c: true }; + expect(hashValue(val)).toBe(hashValue({ c: true, a: { b: [1, "x"] } })); + }); + + test("Date is consistent", () => { + const d = new Date("2024-01-01T00:00:00Z"); + expect(hashValue(d)).toBe(hashValue(new Date("2024-01-01T00:00:00Z"))); + }); + + test("different Dates produce different hashes", () => { + const a = new Date("2024-01-01T00:00:00Z"); + const b = new Date("2024-06-15T12:00:00Z"); + expect(hashValue(a)).not.toBe(hashValue(b)); + }); + + test("Date does not collide with empty object", () => { + expect(hashValue(new Date("2024-01-01"))).not.toBe(hashValue({})); + }); + + test("custom valueOf object hashes by primitive", () => { + const a = { valueOf: () => 42 }; + const b = { valueOf: () => 99 }; + expect(hashValue(a)).not.toBe(hashValue(b)); + expect(hashValue(a)).toBe(hashValue({ valueOf: () => 42 })); + }); + + test("plain object ignores default valueOf", () => { + // Plain objects should still hash by keys, not valueOf + expect(hashValue({ x: 1 })).not.toBe(hashValue({ y: 1 })); + }); +}); diff --git a/packages/apollo-forest-run/src/jsutils/selectionHash.ts b/packages/apollo-forest-run/src/jsutils/selectionHash.ts index 0ba3e3ea1..0d4c90c7b 100644 --- a/packages/apollo-forest-run/src/jsutils/selectionHash.ts +++ b/packages/apollo-forest-run/src/jsutils/selectionHash.ts @@ -4,6 +4,15 @@ * Order-independent at the field level (uses commutative accumulation). */ +const TYPE_STRING = 1; +const TYPE_NUMBER = 2; +const TYPE_BOOLEAN = 3; +const TYPE_BIGINT = 4; + +// Reusable buffers for reading float64 bits as two uint32 values +const f64Buf = new Float64Array(1); +const u32Buf = new Uint32Array(f64Buf.buffer); + export function hashString(s: string): number { let h = 0x811c9dc5; // FNV offset basis for (let i = 0; i < s.length; i++) { @@ -28,11 +37,18 @@ export function hashValue(val: unknown): number { if (val === null || val === undefined) return 0; switch (typeof val) { case "string": - return hashString(val); - case "number": - return val | 0; + return combineHash(TYPE_STRING, hashString(val)); + case "number": { + f64Buf[0] = val; + return combineHash(TYPE_NUMBER, combineHash(u32Buf[0], u32Buf[1])); + } case "boolean": - return val ? 1 : 0; + return combineHash(TYPE_BOOLEAN, val ? 1 : 0); + case "bigint": { + const n = Number(val); + f64Buf[0] = n; + return combineHash(TYPE_BIGINT, combineHash(u32Buf[0], u32Buf[1])); + } default: { if (Array.isArray(val)) { let h = 0xa5a5a5a5; @@ -41,6 +57,16 @@ export function hashValue(val: unknown): number { } return h; } + // Objects with numeric valueOf (e.g. Date) — hash by primitive value + if ( + typeof (val as { valueOf?: unknown }).valueOf === "function" && + (val as { valueOf: () => unknown }).valueOf !== Object.prototype.valueOf + ) { + const prim = (val as { valueOf: () => unknown }).valueOf(); + if (typeof prim === "number" || typeof prim === "string") { + return hashValue(prim); + } + } // Object: order-independent accumulation (no sort needed) const obj = val as Record; let h = 0x5a5a5a5a; From d4e3d6f6721d8a76d787b6f60516cf7c6ac58a6b Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Mon, 16 Mar 2026 17:34:00 +0100 Subject: [PATCH 16/20] detect selections to be resolved early --- .../__tests__/resolvedSelection.test.ts | 51 +++++++ .../src/descriptor/possibleSelection.ts | 26 +++- .../src/descriptor/resolvedSelection.ts | 130 +++--------------- .../apollo-forest-run/src/descriptor/types.ts | 2 + 4 files changed, 100 insertions(+), 109 deletions(-) diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index a310225f5..61a6a187a 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -1700,6 +1700,57 @@ describe(resolvedSelectionsAreEqual, () => { expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); }); }); + + it("does not resolve unseen types during cross-doc hash computation", () => { + const queryA = ` + query A($id: ID!) { + node(id: $id) { + ... on User { name } + ... on Post { title } + ... on Comment { body } + } + } + `; + const queryB = ` + query B($id: ID!) { + node(id: $id) { + ... on User { name } + ... on Post { title } + ... on Comment { body } + } + } + `; + const opA = createTestOperation(queryA, { id: "1" }); + const opB = createTestOperation(queryB, { id: "1" }); + + // Only resolve "User" type for child selections (simulating runtime traversal) + const nodeFieldA = getFieldInfo(opA.possibleSelections, ["node"]); + const nodeFieldB = getFieldInfo(opB.possibleSelections, ["node"]); + + resolveSelection(opA, opA.possibleSelections, null); + resolveSelection(opA, nodeFieldA.selection!, "User"); + + resolveSelection(opB, opB.possibleSelections, null); + resolveSelection(opB, nodeFieldB.selection!, "User"); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + // Cross-doc comparison triggers computeResolvedHash + resolvedSelectionsAreEqual(selA, selB); + + // Verify: only "User" (and possibly null) should be in selections for node's possibleSelections. + // "Post" and "Comment" should NOT have been resolved. + const resolvedTypesA = opA.selections.get(nodeFieldA.selection!); + const resolvedTypesB = opB.selections.get(nodeFieldB.selection!); + + for (const [typeName] of resolvedTypesA ?? []) { + expect(["User", null]).toContain(typeName); + } + for (const [typeName] of resolvedTypesB ?? []) { + expect(["User", null]).toContain(typeName); + } + }); }); describe(fieldEntriesAreEqual, () => { diff --git a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts index c5d4b22f2..9b3dd7579 100644 --- a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts @@ -372,10 +372,11 @@ function completeSelections( completeSelections(context, selection, depth + 1); } - // Compute structural hashes bottom-up (children already completed above) + // Compute structural hashes and complex descendants bottom-up (children already completed above) for (const selection of possibleSelections.values()) { if (selection.structuralHash !== 0) continue; // already hashed (shared selection) selection.structuralHash = computeStructuralHash(selection); + computeHasDescendantsToResolve(selection); } return possibleSelections; @@ -817,6 +818,29 @@ function computeStructuralHash(selection: PossibleSelection): number { return hash || 1; // Avoid 0 which means "not yet computed" } +function computeHasDescendantsToResolve(selection: PossibleSelection) { + if (!selection.fieldsWithSelections) return; + + for (const fieldName of selection.fieldsWithSelections) { + const fields = selection.fields.get(fieldName); + if (!fields) continue; + for (const field of fields) { + if (!field.selection) continue; + for (const [, childSel] of field.selection) { + if ( + childSel.fieldsToNormalize?.length || + childSel.fieldsWithDirectives?.length || + childSel.spreadsWithDirectives?.length || + childSel.hasDescendantsToResolve + ) { + selection.hasDescendantsToResolve = true; + return; + } + } + } + } +} + function createEmptySelection(): PossibleSelection { return { fields: new Map(), fieldQueue: [], depth: -1, structuralHash: 0 }; } diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index fd49b41e5..8db825e1d 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -7,7 +7,6 @@ import type { KeySpecifier, NormalizedFieldEntry, OperationDescriptor, - PossibleSelection, PossibleSelections, ResolvedSelection, TypeName, @@ -111,7 +110,7 @@ export function resolveSelection( skippedSpreads?.size || fieldQueue !== selection.fieldQueue; - if (hasLocalChanges) { + if (hasLocalChanges || selection.hasDescendantsToResolve) { resolvedSelection = { ...selection, fieldQueue, @@ -119,16 +118,9 @@ export function resolveSelection( skippedFields, skippedSpreads, }; - } else if (hasVariableDependentDescendants(selection)) { - resolvedSelection = { ...selection, fieldQueue }; - } else { - resolvedSelection = selection; - } - - if (resolvedSelection !== selection) { selectionOperations.set(resolvedSelection, operation); } else { - // No runtime changes - resolved hash equals structural hash (free) + resolvedSelection = selection; resolvedSelection.resolvedHash = selection.structuralHash; } @@ -154,21 +146,14 @@ function computeResolvedHash( hash = combineHash(hash, hashString(argName)); hash = combineHash(hash, hashValue(argVal)); } - if (typeof entry.keyArgs === "string") { - hash = combineHash(hash, hashString(entry.keyArgs)); - } else if (entry.keyArgs) { - for (const k of entry.keyArgs) { - hash = combineHash(hash, hashString(k)); - } - } } } } - // Mix in resolved non-inclusion directive values - if (selection.fieldsToNormalize?.length) { + // Mix in resolved non-inclusion directive values (e.g. @connection) + if (selection.fieldsWithDirectives?.length) { const variables = operation.variablesWithDefaults; - for (const field of selection.fieldsToNormalize) { + for (const field of selection.fieldsWithDirectives) { for (const ref of field.__refs) { if (!ref.node.directives) continue; for (const dir of ref.node.directives) { @@ -201,20 +186,29 @@ function computeResolvedHash( } } - // Mix in child resolved hashes for fields with variable-dependent descendants + // Mix in child resolved hashes (only for types already resolved during traversal) if (selection.fieldsWithSelections) { for (const fieldName of selection.fieldsWithSelections) { const fields = selection.fields.get(fieldName); if (!fields) continue; for (const field of fields) { if (!field.selection) continue; - for (const typeName of field.selection.keys()) { - const childResolved = resolveSelection( - operation, - field.selection, - typeName, - ); - hash = combineHash(hash, getResolvedHash(childResolved)); + const resolved = operation.selections.get(field.selection); + if (resolved) { + // Use only types that were actually encountered at runtime + for (const [, childResolved] of resolved) { + hash = combineHash(hash, getResolvedHash(childResolved)); + } + } else { + // Child not yet traversed — resolve all possible types as fallback + for (const typeName of field.selection.keys()) { + const childResolved = resolveSelection( + operation, + field.selection, + typeName, + ); + hash = combineHash(hash, getResolvedHash(childResolved)); + } } } } @@ -241,90 +235,10 @@ export function resolvedSelectionsAreEqual( if (a === b) { return true; } - if (a.fields === b.fields) { - return sameDocSelectionsAreEqual(a, b); - } - // Cross-doc: structural hash is deterministic from doc structure, so inequality is definitive if (a.structuralHash !== b.structuralHash) return false; - // Structure matches - compare resolved hash (computed lazily, includes variable-dependent state) return getResolvedHash(a) === getResolvedHash(b); } -function sameDocSelectionsAreEqual( - a: ResolvedSelection, - b: ResolvedSelection, -): boolean { - if (a.skippedFields?.size !== b.skippedFields?.size) { - return false; - } - assert(a.normalizedFields?.size === b.normalizedFields?.size); - - for (const [alias, aNormalized] of a.normalizedFields?.entries() ?? - EMPTY_ARRAY) { - const bNormalized = b.normalizedFields?.get(alias); - assert(aNormalized && bNormalized); - if (!fieldEntriesAreEqual(aNormalized, bNormalized)) { - return false; - } - } - for (const aSkipped of a.skippedFields ?? EMPTY_ARRAY) { - if (!b.skippedFields?.has(aSkipped)) { - return false; - } - } - // Bug 3 fix: compare skippedSpreads - if (a.skippedSpreads?.size !== b.skippedSpreads?.size) { - return false; - } - for (const aSpread of a.skippedSpreads ?? EMPTY_ARRAY) { - if (!b.skippedSpreads?.has(aSpread)) { - return false; - } - } - // Bug 2 fix: recursively compare child selections - const aOp = selectionOperations.get(a); - const bOp = selectionOperations.get(b); - if (aOp && bOp && a.fieldsWithSelections) { - for (const fieldName of a.fieldsWithSelections) { - const fields = a.fields.get(fieldName); - if (!fields) continue; - for (const field of fields) { - if (!field.selection) continue; - for (const typeName of field.selection.keys()) { - const childA = resolveSelection(aOp, field.selection, typeName); - const childB = resolveSelection(bOp, field.selection, typeName); - if (!resolvedSelectionsAreEqual(childA, childB)) return false; - } - } - } - } - return true; -} - -function hasVariableDependentDescendants( - selection: PossibleSelection, -): boolean { - if (!selection.fieldsWithSelections) return false; - for (const fieldName of selection.fieldsWithSelections) { - const fields = selection.fields.get(fieldName); - if (!fields) continue; - for (const field of fields) { - if (!field.selection) continue; - for (const [, childSel] of field.selection) { - if ( - childSel.fieldsToNormalize?.length || - childSel.fieldsWithDirectives?.length || - childSel.spreadsWithDirectives?.length || - hasVariableDependentDescendants(childSel) - ) { - return true; - } - } - } - } - return false; -} - export function resolveNormalizedField( selection: ResolvedSelection, field: FieldInfo, diff --git a/packages/apollo-forest-run/src/descriptor/types.ts b/packages/apollo-forest-run/src/descriptor/types.ts index e90833386..d48e63db6 100644 --- a/packages/apollo-forest-run/src/descriptor/types.ts +++ b/packages/apollo-forest-run/src/descriptor/types.ts @@ -152,6 +152,8 @@ export type PossibleSelection = { experimentalAlias?: FragmentAlias; experimentalAliasedFragments?: Map; + hasDescendantsToResolve?: boolean; // true if any descendant has fieldsToNormalize, fieldsWithDirectives, or spreadsWithDirectives + structuralHash: number; // Order-independent hash of field structure (names, dataKeys, arg names, child hashes) }; From eecb8ee507c5ff92d8604c40694638ab487b9de4 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Mon, 16 Mar 2026 19:25:43 +0100 Subject: [PATCH 17/20] take keyArgs into account --- .../src/__tests__/helpers/descriptor.ts | 25 ++++ .../src/__tests__/regression.test.ts | 120 ++++++++++++++++++ .../__tests__/resolvedSelection.test.ts | 76 +++++++++++ .../src/descriptor/resolvedSelection.ts | 14 +- 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/packages/apollo-forest-run/src/__tests__/helpers/descriptor.ts b/packages/apollo-forest-run/src/__tests__/helpers/descriptor.ts index 3b474288c..b2b48696c 100644 --- a/packages/apollo-forest-run/src/__tests__/helpers/descriptor.ts +++ b/packages/apollo-forest-run/src/__tests__/helpers/descriptor.ts @@ -8,6 +8,7 @@ import { FieldInfo, FieldMap, OperationDescriptor, + OperationEnv, PossibleSelection, PossibleSelections, VariableValues, @@ -82,6 +83,30 @@ export function createTestOperation( ); } +export function createTestOperationWithEnv( + env: OperationEnv, + documentOrString: DocumentNode | string, + variables?: VariableValues, +): OperationDescriptor { + const document = + typeof documentOrString === "string" + ? parseOnce(documentOrString) + : documentOrString; + + let resultDescriptor = descriptorCache.get(documentOrString); + if (!resultDescriptor) { + resultDescriptor = describeResultTree(describeDocument(document)); + descriptorCache.set(documentOrString, resultDescriptor); + } + + return describeOperation( + env, + describeDocument(document), + resultDescriptor, + variables ?? {}, + ); +} + export function getFieldInfo( selection: PossibleSelection | PossibleSelections, path: string[], diff --git a/packages/apollo-forest-run/src/__tests__/regression.test.ts b/packages/apollo-forest-run/src/__tests__/regression.test.ts index 2272ae667..fdbb4e591 100644 --- a/packages/apollo-forest-run/src/__tests__/regression.test.ts +++ b/packages/apollo-forest-run/src/__tests__/regression.test.ts @@ -791,6 +791,126 @@ test("should keep a single result for multiple operations with the same key vari expect(statsAfterDiff.treeCount).toBe(2); }); +test("merge policy with keyArgs: watch sees correct edges after paginated writes", () => { + const cache = new ForestRun({ + typePolicies: { + Query: { + fields: { + search: { + keyArgs: (args: Record | null) => { + return args?.query?.toLowerCase(); + }, + merge: (existing: any, incoming: any, { args }: any) => { + const merged = existing ? { ...existing } : {}; + merged.edges = existing?.edges ? existing.edges.slice(0) : []; + + if (args?.after) { + merged.edges.push(...incoming.edges); + } else if (args?.before) { + merged.edges.unshift(...incoming.edges); + } else { + merged.edges = incoming.edges; + } + merged.totalCount = incoming.totalCount; + return merged; + }, + }, + }, + }, + }, + }); + + const query = gql` + query ( + $query: String! + $after: String + $first: Int + $before: String + $last: Int + ) { + search( + query: $query + after: $after + first: $first + before: $before + last: $last + ) { + edges { + __typename + node + } + totalCount + } + } + `; + + const notifications: any[] = []; + const firstVars = { query: "Basquiat", first: 3 }; + cache.watch({ + query, + variables: firstVars, + optimistic: true, + callback: (diff) => notifications.push(diff.result), + }); + + // Write page 1 + cache.write({ + query, + variables: firstVars, + result: { + search: { + edges: [ + { __typename: "E", node: "A" }, + { __typename: "E", node: "B" }, + { __typename: "E", node: "C" }, + ], + totalCount: 10, + }, + }, + }); + expect(notifications.length).toBe(1); + expect((notifications[0] as any).search.edges.length).toBe(3); + + // Write page 2 (forward) + cache.write({ + query, + variables: { query: "Basquiat", after: "curC", first: 3 }, + result: { + search: { + edges: [ + { __typename: "E", node: "D" }, + { __typename: "E", node: "E" }, + { __typename: "E", node: "F" }, + ], + totalCount: 10, + }, + }, + }); + expect(notifications.length).toBe(2); + expect((notifications[1] as any).search.edges.length).toBe(6); + + // Write page 3 (backward, lowercase query = same keyArgs) + cache.write({ + query, + variables: { query: "basquiat", before: "curD", last: 2 }, + result: { + search: { + edges: [ + { __typename: "E", node: "B2" }, + { __typename: "E", node: "C2" }, + ], + totalCount: 10, + }, + }, + }); + + // Watcher should see merged result: B2, C2 prepended to [A,B,C,D,E,F] = 8 edges + // Bug: without keyArgs-aware hash, watcher's selection doesn't match the + // new write's selection (different args hash), so notification uses stale data + expect(notifications.length).toBe(3); + expect((notifications[2] as any).search.edges.length).toBe(8); +}); + test("bad manual writes shouldn't cause invariant violation", () => { const query = gql` { diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index 61a6a187a..672b012cd 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -2,6 +2,7 @@ import { createTestOperation, getFieldInfo, } from "../../__tests__/helpers/descriptor"; +import { createTestOperationWithEnv } from "../../__tests__/helpers/descriptor"; import { assert } from "../../jsutils/assert"; import { resolveSelection, @@ -1701,6 +1702,81 @@ describe(resolvedSelectionsAreEqual, () => { }); }); + it("treats selections as equal when keyArgs match but other args differ", () => { + const query = ` + query ($query: String!, $after: String, $first: Int, $before: String, $last: Int) { + search(query: $query, after: $after, first: $first, before: $before, last: $last) { + edges { node } + } + } + `; + const env = { + keyArgs: ( + _typeName: string, + fieldName: string, + args?: Map, + ) => { + if (fieldName === "search" && args) { + return String(args.get("query")).toLowerCase(); + } + return undefined; + }, + }; + + const opA = createTestOperationWithEnv(env, query, { + query: "Basquiat", + first: 3, + }); + const opB = createTestOperationWithEnv(env, query, { + query: "basquiat", + before: "cursor", + last: 2, + }); + + // Must use "Query" typeName so keyArgs function is invoked + const selA = resolveSelection(opA, opA.possibleSelections, "Query"); + const selB = resolveSelection(opB, opB.possibleSelections, "Query"); + + // Same keyArgs ("basquiat") — pagination args (after/before/first/last) are ignored + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + + it("treats selections as not equal when keyArgs differ", () => { + const query = ` + query ($query: String!, $first: Int) { + search(query: $query, first: $first) { + edges { node } + } + } + `; + const env = { + keyArgs: ( + _typeName: string, + fieldName: string, + args?: Map, + ) => { + if (fieldName === "search" && args) { + return String(args.get("query")).toLowerCase(); + } + return undefined; + }, + }; + + const opA = createTestOperationWithEnv(env, query, { + query: "Basquiat", + first: 3, + }); + const opB = createTestOperationWithEnv(env, query, { + query: "Turrell", + first: 3, + }); + + const selA = resolveSelection(opA, opA.possibleSelections, "Query"); + const selB = resolveSelection(opB, opB.possibleSelections, "Query"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + it("does not resolve unseen types during cross-doc hash computation", () => { const queryA = ` query A($id: ID!) { diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index 8db825e1d..64ef6744d 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -135,11 +135,23 @@ function computeResolvedHash( ): number { let hash = selection.structuralHash; - // Mix in resolved arg values (handles both hardcoded and variable args) + // Mix in resolved arg values. When keyArgs is defined, only hash the keyArgs + // (not all args) to match fieldEntriesAreEqual semantics — pagination args + // like after/before/first/last should not affect selection equality. if (selection.normalizedFields?.size) { for (const [, entry] of selection.normalizedFields) { if (typeof entry === "string") { hash = combineHash(hash, hashString(entry)); + } else if (typeof entry.keyArgs === "string") { + hash = combineHash(hash, hashString(entry.name)); + hash = combineHash(hash, hashString(entry.keyArgs)); + } else if (entry.keyArgs) { + hash = combineHash(hash, hashString(entry.name)); + for (const k of entry.keyArgs) { + hash = combineHash(hash, hashString(k)); + const val = entry.args.get(k); + if (val !== undefined) hash = combineHash(hash, hashValue(val)); + } } else { hash = combineHash(hash, hashString(entry.name)); for (const [argName, argVal] of entry.args) { From 4c077db677e524d1dfa88069ea382d68fa84e3de Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 18 Mar 2026 20:33:01 +0100 Subject: [PATCH 18/20] more resilient implementation, more tests --- .../__tests__/resolvedSelection.test.ts | 209 +++++++++++++++++- .../src/descriptor/possibleSelection.ts | 33 +-- .../src/descriptor/resolvedSelection.ts | 96 +++++--- .../src/diff/__tests__/diffObject.test.ts | 3 +- .../jsutils/__tests__/selectionHash.test.ts | 25 ++- .../src/jsutils/selectionHash.ts | 28 +-- 6 files changed, 322 insertions(+), 72 deletions(-) diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index 672b012cd..34214d6d4 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -9,7 +9,34 @@ import { resolvedSelectionsAreEqual, fieldEntriesAreEqual, } from "../resolvedSelection"; -import { Key, KeySpecifier, NormalizedFieldEntry } from "../types"; +import { + Key, + KeySpecifier, + NormalizedFieldEntry, + OperationDescriptor, + PossibleSelections, +} from "../types"; + +/** Resolve all children of a selection tree (simulates what indexing does) */ +function resolveAllChildren( + op: OperationDescriptor, + possibleSelections: PossibleSelections, +) { + for (const [typeName, selection] of possibleSelections) { + resolveSelection(op, possibleSelections, typeName); + if (selection.fieldsWithSelections) { + for (const fieldName of selection.fieldsWithSelections) { + const fields = selection.fields.get(fieldName); + if (!fields) continue; + for (const field of fields) { + if (field.selection) { + resolveAllChildren(op, field.selection); + } + } + } + } + } +} describe(resolveSelection, () => { it("keeps original static selection for operations without variables", () => { @@ -1144,6 +1171,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1161,6 +1190,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { skip: true }); const opB = createTestOperation(query, { skip: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1177,6 +1208,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { x: 1 }); const opB = createTestOperation(query, { x: 2 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1195,6 +1228,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1213,6 +1248,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { x: "active" }); const opB = createTestOperation(query, { x: "archived" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1229,6 +1266,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: true }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1250,6 +1289,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1376,6 +1417,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, "User"); const selB = resolveSelection(opB, opB.possibleSelections, "User"); @@ -1390,6 +1433,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { cursor: "abc" }); const opB = createTestOperation(query, { cursor: "xyz" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, "User"); const selB = resolveSelection(opB, opB.possibleSelections, "User"); @@ -1479,6 +1524,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1499,6 +1546,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { x: "aaa" }); const opB = createTestOperation(query, { x: "bbb" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1542,6 +1591,8 @@ describe(resolvedSelectionsAreEqual, () => { `; const opA = createTestOperation(query, { inc: true }); const opB = createTestOperation(query, { inc: false }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1614,6 +1665,8 @@ describe(resolvedSelectionsAreEqual, () => { "query B($n: Int!) { foo { bar(limit: $n) { id } } }", { n: 20 }, ); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); const selA = resolveSelection(opA, opA.possibleSelections, null); const selB = resolveSelection(opB, opB.possibleSelections, null); @@ -1777,6 +1830,160 @@ describe(resolvedSelectionsAreEqual, () => { expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); }); + it("returns false for cross-doc selections with different type spreads", () => { + const queryA = `query Foo { node { id, ... on Foo { name } } }`; + const queryB = `query Bar { node { id, ... on Bar { name } } }`; + + const opA = createTestOperation(queryA); + const opB = createTestOperation(queryB); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false for root selections when child has shared type but different other spreads", () => { + const queryA = ` + query A { + node { + ... on TypeA { a } + ... on TypeB { b } + ... on Shared { shared } + } + } + `; + const queryB = ` + query B { + node { + ... on TypeC { c } + ... on TypeD { d } + ... on Shared { shared } + } + } + `; + + const opA = createTestOperation(queryA); + const opB = createTestOperation(queryB); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("returns false for cross-doc nested selections with different child type spreads", () => { + const queryA = `query A { node { id, ... on Foo { name } } }`; + const queryB = `query B { node { id, ... on Bar { name } } }`; + + const opA = createTestOperation(queryA); + const opB = createTestOperation(queryB); + + const nodeFieldA = getFieldInfo(opA.possibleSelections, ["node"]); + const nodeFieldB = getFieldInfo(opB.possibleSelections, ["node"]); + + resolveSelection(opA, nodeFieldA.selection!, "Foo"); + resolveSelection(opB, nodeFieldB.selection!, "Bar"); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("detects inequality when new union type is resolved with different variables", () => { + // Two ops with same query shape but different variables. + // Initially both only encounter "User". Then one encounters "Post" which has + // a variable-dependent arg — equality must update to reflect the new child. + const opA = createTestOperation( + `query A($x: Int!) { + node { + ... on User { name } + ... on Post { title(limit: $x) } + } + }`, + { x: 10 }, + ); + const opB = createTestOperation( + `query B($x: Int!) { + node { + ... on User { name } + ... on Post { title(limit: $x) } + } + }`, + { x: 20 }, + ); + + const nodeFieldA = getFieldInfo(opA.possibleSelections, ["node"]); + const nodeFieldB = getFieldInfo(opB.possibleSelections, ["node"]); + + // Both ops only see "User" type initially + resolveSelection(opA, opA.possibleSelections, null); + resolveSelection(opA, nodeFieldA.selection!, "User"); + + resolveSelection(opB, opB.possibleSelections, null); + resolveSelection(opB, nodeFieldB.selection!, "User"); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + // Equal: both have only User resolved, User has no variable deps + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + + // opA now encounters "Post" (which has variable-dependent arg limit=$x) + resolveSelection(opA, nodeFieldA.selection!, "Post"); + + // Not equal: opA has Post (limit=10), opB doesn't have Post yet + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + + // opB also encounters "Post" (limit=20) + resolveSelection(opB, nodeFieldB.selection!, "Post"); + + // Still not equal: different variable values (10 vs 20) + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(false); + }); + + it("stays equal when new union type resolved without variables", () => { + // Without variables, resolving a new union type for one operation should not + // affect equality — the selection structure is the same regardless of which + // concrete types have been encountered at runtime. + const opA = createTestOperation(` + query A { + node { + ... on User { name } + ... on Post { title } + } + } + `); + const opB = createTestOperation(` + query B { + node { + ... on User { name } + ... on Post { title } + } + } + `); + + const nodeFieldA = getFieldInfo(opA.possibleSelections, ["node"]); + const nodeFieldB = getFieldInfo(opB.possibleSelections, ["node"]); + + // Both resolve User only + resolveSelection(opA, opA.possibleSelections, null); + resolveSelection(opA, nodeFieldA.selection!, "User"); + resolveSelection(opB, opB.possibleSelections, null); + resolveSelection(opB, nodeFieldB.selection!, "User"); + + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + + // opA encounters Post — equality should not change (no variables, same structure) + resolveSelection(opA, nodeFieldA.selection!, "Post"); + + expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); + }); + it("does not resolve unseen types during cross-doc hash computation", () => { const queryA = ` query A($id: ID!) { diff --git a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts index 9b3dd7579..416e323c2 100644 --- a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts @@ -30,6 +30,7 @@ import { hashString, combineHash, accumulateHash, + UNINITIALIZED_HASH, } from "../jsutils/selectionHash"; export type Context = Readonly<{ @@ -374,7 +375,7 @@ function completeSelections( // Compute structural hashes and complex descendants bottom-up (children already completed above) for (const selection of possibleSelections.values()) { - if (selection.structuralHash !== 0) continue; // already hashed (shared selection) + if (selection.structuralHash !== UNINITIALIZED_HASH) continue; // already hashed (shared selection) selection.structuralHash = computeStructuralHash(selection); computeHasDescendantsToResolve(selection); } @@ -742,7 +743,7 @@ function copySelection( fieldQueue: [], experimentalAlias: selection.experimentalAlias, depth: selection.depth, - structuralHash: 0, + structuralHash: UNINITIALIZED_HASH, }; for (const [field, aliases] of selection.fields.entries()) { copy.fields.set(field, [...aliases]); @@ -772,8 +773,8 @@ function copySelection( /** * Compute an order-independent structural hash of a PossibleSelection. - * Hashes: field names, dataKeys, arg names, non-inclusion directive names, - * child selection hashes, and spread names. + * Hashes own fields only: field names, dataKeys, arg names. + * Does NOT hash children or type names (those are handled in the resolved hash). * Does NOT hash arg/directive values (those depend on variables, handled at resolve time). */ function computeStructuralHash(selection: PossibleSelection): number { @@ -786,36 +787,20 @@ function computeStructuralHash(selection: PossibleSelection): number { fieldHash = combineHash(fieldHash, hashString(entry.dataKey)); // Hash arg names (sorted for stability — same args in different AST order should match) + // FIXME: no need to sort? separate argHash that is order-dependent and combine with fieldHash in an order-independent way? if (entry.args?.size) { const argNames = [...entry.args.keys()].sort(); for (const argName of argNames) { fieldHash = combineHash(fieldHash, hashString(argName)); } } - - // Note: non-inclusion directive names+values are hashed in the resolved hash - // (they may have variable-dependent arg values) - - // Hash child selection structure (already computed bottom-up) - if (entry.selection) { - for (const [typeName, childSel] of entry.selection) { - fieldHash = combineHash( - fieldHash, - typeName ? hashString(typeName) : 0, - ); - fieldHash = combineHash(fieldHash, childSel.structuralHash); - } - } } // Accumulate order-independently across field names hash = accumulateHash(hash, fieldHash); } + // TODO: fieldsWithDirectives + spreadsWithDirectives should also affect structural hash (only directives + arg names) - // Note: spreads are NOT hashed — they're only relevant for skippedSpreads - // which is handled in the resolved hash. Non-skipped spreads are already - // accounted for via merged fields. - - return hash || 1; // Avoid 0 which means "not yet computed" + return hash; } function computeHasDescendantsToResolve(selection: PossibleSelection) { @@ -842,7 +827,7 @@ function computeHasDescendantsToResolve(selection: PossibleSelection) { } function createEmptySelection(): PossibleSelection { - return { fields: new Map(), fieldQueue: [], depth: -1, structuralHash: 0 }; + return { fields: new Map(), fieldQueue: [], depth: -1, structuralHash: UNINITIALIZED_HASH }; } function getFragmentAlias(node: FragmentSpreadNode): string | undefined { diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index 64ef6744d..923a5ec43 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -24,7 +24,12 @@ import { valueFromASTUntyped } from "graphql"; import { equal } from "@wry/equality"; import { assert } from "../jsutils/assert"; import { createArgumentDefs } from "./possibleSelection"; -import { hashString, combineHash, hashValue } from "../jsutils/selectionHash"; +import { + hashString, + combineHash, + hashValue, + UNINITIALIZED_HASH, +} from "../jsutils/selectionHash"; const EMPTY_ARRAY = Object.freeze([]); const EMPTY_MAP = new Map(); @@ -117,11 +122,14 @@ export function resolveSelection( normalizedFields, skippedFields, skippedSpreads, + resolvedHash: UNINITIALIZED_HASH, }; selectionOperations.set(resolvedSelection, operation); + + // New child may change parent hashes — invalidate existing cloned selections + invalidateResolvedHashes(operation); } else { resolvedSelection = selection; - resolvedSelection.resolvedHash = selection.structuralHash; } map.set(typeName, resolvedSelection); @@ -129,8 +137,24 @@ export function resolveSelection( return resolvedSelection; } +/** Reset resolvedHash on cloned resolved selections for this operation. + * Only cloned selections (those in selectionOperations WeakMap) are operation-specific. + * Non-cloned selections are shared PossibleSelections whose hash is deterministic. */ +function invalidateResolvedHashes(operation: OperationDescriptor): void { + for (const typeMap of operation.selections.values()) { + for (const sel of typeMap.values()) { + if ( + sel.resolvedHash !== UNINITIALIZED_HASH && + selectionOperations.has(sel) + ) { + sel.resolvedHash = UNINITIALIZED_HASH; + } + } + } +} + function computeResolvedHash( - operation: OperationDescriptor, + operation: OperationDescriptor | undefined, selection: ResolvedSelection, ): number { let hash = selection.structuralHash; @@ -164,6 +188,7 @@ function computeResolvedHash( // Mix in resolved non-inclusion directive values (e.g. @connection) if (selection.fieldsWithDirectives?.length) { + assert(operation); const variables = operation.variablesWithDefaults; for (const field of selection.fieldsWithDirectives) { for (const ref of field.__refs) { @@ -198,28 +223,28 @@ function computeResolvedHash( } } - // Mix in child resolved hashes (only for types already resolved during traversal) + // Mix in children hashes (including type keys) if (selection.fieldsWithSelections) { for (const fieldName of selection.fieldsWithSelections) { const fields = selection.fields.get(fieldName); if (!fields) continue; for (const field of fields) { if (!field.selection) continue; - const resolved = operation.selections.get(field.selection); - if (resolved) { - // Use only types that were actually encountered at runtime - for (const [, childResolved] of resolved) { - hash = combineHash(hash, getResolvedHash(childResolved)); + const resolvedMap = operation?.selections.get(field.selection); + for (const [typeName] of field.selection) { + // Hash type key so that different type spreads are distinguished + if (typeName !== null) { + hash = combineHash(hash, hashString(typeName)); } - } else { - // Child not yet traversed — resolve all possible types as fallback - for (const typeName of field.selection.keys()) { - const childResolved = resolveSelection( - operation, - field.selection, - typeName, - ); - hash = combineHash(hash, getResolvedHash(childResolved)); + // For variable-dependent selections, use operation.selections to find resolved children + // For non-variable selections, use PossibleSelection directly (it IS the resolved selection) + const child = + resolvedMap?.get(typeName) ?? + (!selection.hasDescendantsToResolve + ? field.selection.get(typeName) ?? field.selection.get(null) + : undefined); + if (child) { + hash = combineHash(hash, getResolvedHash(child, operation)); } } } @@ -230,23 +255,40 @@ function computeResolvedHash( } /** Lazily compute resolved hash on first cross-doc comparison */ -function getResolvedHash(selection: ResolvedSelection): number { - if (selection.resolvedHash !== undefined) { +function getResolvedHash( + selection: ResolvedSelection, + operation?: OperationDescriptor, +): number { + if ( + selection.resolvedHash !== undefined && + selection.resolvedHash !== UNINITIALIZED_HASH + ) { + return selection.resolvedHash; + } + if ( + !selection.normalizedFields && + !selection.skippedFields && + !selection.skippedSpreads && + !selection.fieldsWithDirectives && + !selection.fieldsWithSelections + ) { + selection.resolvedHash = selection.structuralHash; return selection.resolvedHash; } - const op = selectionOperations.get(selection); - assert(op); - selection.resolvedHash = computeResolvedHash(op, selection); - return selection.resolvedHash; + const op = operation ?? selectionOperations.get(selection); + const hash = computeResolvedHash(op, selection); + // Only cache when we have a complete picture (operation available for child hashing) + if (op) { + selection.resolvedHash = hash; + } + return hash; } export function resolvedSelectionsAreEqual( a: ResolvedSelection, b: ResolvedSelection, ): boolean { - if (a === b) { - return true; - } + if (a === b) return true; if (a.structuralHash !== b.structuralHash) return false; return getResolvedHash(a) === getResolvedHash(b); } diff --git a/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts b/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts index 7af9437fa..29d9e822e 100644 --- a/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts +++ b/packages/apollo-forest-run/src/diff/__tests__/diffObject.test.ts @@ -1,6 +1,7 @@ import { cloneDeep } from "lodash"; import { diffObject } from "../diffObject"; import { PossibleSelections, VariableValues } from "../../descriptor/types"; +import { UNINITIALIZED_HASH } from "../../jsutils/selectionHash"; import { assert } from "../../jsutils/assert"; import { ObjectChunk } from "../../values/types"; import { SourceObject } from "../../values/types"; @@ -2076,7 +2077,7 @@ function chunkPerField(env: DiffEnv, sourceChunk: ObjectChunk): ObjectChunk[] { fieldsWithSelections: selection.fieldsWithSelections?.includes(name) ? [name] : [], - structuralHash: 0, + structuralHash: UNINITIALIZED_HASH, }, ], ]); diff --git a/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts index 205ffeb4c..551ff3605 100644 --- a/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts +++ b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts @@ -161,15 +161,30 @@ describe("hashValue", () => { expect(hashValue(new Date("2024-01-01"))).not.toBe(hashValue({})); }); - test("custom valueOf object hashes by primitive", () => { + test("Date hashes by timestamp, not object keys", () => { + const a = new Date("2024-01-01"); + const b = new Date("2024-01-01"); + const c = new Date("2025-06-15"); + expect(hashValue(a)).toBe(hashValue(b)); + expect(hashValue(a)).not.toBe(hashValue(c)); + }); + + test("custom valueOf objects hash by keys, not primitive", () => { + // Custom valueOf is NOT used for hashing (only Date is special-cased) const a = { valueOf: () => 42 }; const b = { valueOf: () => 99 }; - expect(hashValue(a)).not.toBe(hashValue(b)); - expect(hashValue(a)).toBe(hashValue({ valueOf: () => 42 })); + // Both have same keys ("valueOf") so they hash the same by keys + expect(hashValue(a)).toBe(hashValue(b)); }); - test("plain object ignores default valueOf", () => { - // Plain objects should still hash by keys, not valueOf + test("plain object hashes by keys", () => { expect(hashValue({ x: 1 })).not.toBe(hashValue({ y: 1 })); }); + + test("bigint hashes correctly for values exceeding Number precision", () => { + const a = BigInt("9007199254740993"); // 2^53 + 1 + const b = BigInt("9007199254740994"); // 2^53 + 2 + // These would collide with Number() conversion but not with toString() + expect(hashValue(a)).not.toBe(hashValue(b)); + }); }); diff --git a/packages/apollo-forest-run/src/jsutils/selectionHash.ts b/packages/apollo-forest-run/src/jsutils/selectionHash.ts index 0d4c90c7b..9e025ac70 100644 --- a/packages/apollo-forest-run/src/jsutils/selectionHash.ts +++ b/packages/apollo-forest-run/src/jsutils/selectionHash.ts @@ -2,8 +2,16 @@ * Lightweight hash utilities for PossibleSelection structural hashing. * Uses FNV-1a-inspired mixing for combining string hashes and numbers. * Order-independent at the field level (uses commutative accumulation). + * + * All hash functions return uint32 values (0 to 4294967295) via `>>> 0`. */ +/** Sentinel for uninitialized hash fields. + * - Outside uint32 range (0 to 2^32-1), so no collision with `>>> 0` outputs. + * - Outside V8 SMI range, so the property starts as "double" representation + * avoiding SMI→Double hidden class transition when real hashes are stored. */ +export const UNINITIALIZED_HASH = 0x100000000; + const TYPE_STRING = 1; const TYPE_NUMBER = 2; const TYPE_BOOLEAN = 3; @@ -44,11 +52,8 @@ export function hashValue(val: unknown): number { } case "boolean": return combineHash(TYPE_BOOLEAN, val ? 1 : 0); - case "bigint": { - const n = Number(val); - f64Buf[0] = n; - return combineHash(TYPE_BIGINT, combineHash(u32Buf[0], u32Buf[1])); - } + case "bigint": + return combineHash(TYPE_BIGINT, hashString(val.toString())); default: { if (Array.isArray(val)) { let h = 0xa5a5a5a5; @@ -57,15 +62,10 @@ export function hashValue(val: unknown): number { } return h; } - // Objects with numeric valueOf (e.g. Date) — hash by primitive value - if ( - typeof (val as { valueOf?: unknown }).valueOf === "function" && - (val as { valueOf: () => unknown }).valueOf !== Object.prototype.valueOf - ) { - const prim = (val as { valueOf: () => unknown }).valueOf(); - if (typeof prim === "number" || typeof prim === "string") { - return hashValue(prim); - } + // Date: hash by numeric timestamp + if (val instanceof Date) { + f64Buf[0] = val.getTime(); + return combineHash(TYPE_NUMBER, combineHash(u32Buf[0], u32Buf[1])); } // Object: order-independent accumulation (no sort needed) const obj = val as Record; From 00194abb3fffdf7ef70220f33c4ca47b7e4f7aab Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 18 Mar 2026 20:50:54 +0100 Subject: [PATCH 19/20] lint --- .../apollo-forest-run/src/descriptor/possibleSelection.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts index 416e323c2..bcbe2c172 100644 --- a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts @@ -827,7 +827,12 @@ function computeHasDescendantsToResolve(selection: PossibleSelection) { } function createEmptySelection(): PossibleSelection { - return { fields: new Map(), fieldQueue: [], depth: -1, structuralHash: UNINITIALIZED_HASH }; + return { + fields: new Map(), + fieldQueue: [], + depth: -1, + structuralHash: UNINITIALIZED_HASH, + }; } function getFragmentAlias(node: FragmentSpreadNode): string | undefined { From 1848528825ebe3700503be006d202c8b3572f3f2 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Wed, 18 Mar 2026 21:20:25 +0100 Subject: [PATCH 20/20] fix perf regression --- .../__tests__/resolvedSelection.test.ts | 38 +++++++++++++++++++ .../src/descriptor/resolvedSelection.ts | 8 ++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts index 34214d6d4..38bff8fa3 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -1984,6 +1984,44 @@ describe(resolvedSelectionsAreEqual, () => { expect(resolvedSelectionsAreEqual(selA, selB)).toBe(true); }); + it("caches resolvedHash for non-cloned selections after comparison", () => { + // Regression: non-cloned selections (no variables) must cache resolvedHash. + // Without caching, every comparison recomputes the entire subtree hash. + const opA = createTestOperation(` + query A { + node { + ... on User { name } + ... on Post { title } + } + } + `); + const opB = createTestOperation(` + query B { + node { + ... on User { name } + ... on Post { title } + } + } + `); + + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + const selA = resolveSelection(opA, opA.possibleSelections, null); + const selB = resolveSelection(opB, opB.possibleSelections, null); + + // Before comparison: no cached hash + expect(selA.resolvedHash).toBeUndefined(); + expect(selB.resolvedHash).toBeUndefined(); + + resolvedSelectionsAreEqual(selA, selB); + + // After comparison: hash must be cached (not undefined/UNINITIALIZED_HASH) + expect(typeof selA.resolvedHash).toBe("number"); + expect(typeof selB.resolvedHash).toBe("number"); + expect(selA.resolvedHash).toBeGreaterThanOrEqual(0); + expect(selB.resolvedHash).toBeGreaterThanOrEqual(0); + }); + it("does not resolve unseen types during cross-doc hash computation", () => { const queryA = ` query A($id: ID!) { diff --git a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts index 923a5ec43..b67a89527 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -277,10 +277,10 @@ function getResolvedHash( } const op = operation ?? selectionOperations.get(selection); const hash = computeResolvedHash(op, selection); - // Only cache when we have a complete picture (operation available for child hashing) - if (op) { - selection.resolvedHash = hash; - } + // Cache the hash. For non-cloned selections (no op), the hash is deterministic + // from PossibleSelections structure. For cloned selections, it's operation-specific + // and invalidated by invalidateResolvedHashes when new types are resolved. + selection.resolvedHash = hash; return hash; }