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" +} 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..979ed44ed --- /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) @cache(covers: ["CommentsList", "PostHeader"]) { + ...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/package.json b/packages/apollo-forest-run-benchmark/package.json index 303879111..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": "monorepo-scripts types", + "types": "node -e 0", "benchmark": "yarn build && node --expose-gc ./lib/index.js", "clone": "./scripts/clone-caches.sh", "just": "monorepo-scripts" 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/package.json b/packages/apollo-forest-run/package.json index 60354adfa..25fa818e6 100644 --- a/packages/apollo-forest-run/package.json +++ b/packages/apollo-forest-run/package.json @@ -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 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__/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/__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/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 23b565d45..38bff8fa3 100644 --- a/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts +++ b/packages/apollo-forest-run/src/descriptor/__tests__/resolvedSelection.test.ts @@ -1,11 +1,42 @@ -import { createTestOperation } from "../../__tests__/helpers/descriptor"; +import { + createTestOperation, + getFieldInfo, +} from "../../__tests__/helpers/descriptor"; +import { createTestOperationWithEnv } from "../../__tests__/helpers/descriptor"; import { assert } from "../../jsutils/assert"; import { resolveSelection, 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", () => { @@ -959,6 +990,1088 @@ 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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" }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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 }); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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); + }); + }); + + // --- 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 }, + ); + resolveAllChildren(opA, opA.possibleSelections); + resolveAllChildren(opB, opB.possibleSelections); + 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); + }); + }); + + 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("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("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!) { + 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/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..bcbe2c172 100644 --- a/packages/apollo-forest-run/src/descriptor/possibleSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/possibleSelection.ts @@ -26,6 +26,12 @@ import type { } from "./types"; import { accumulate, getOrCreate } from "../jsutils/map"; import { assert, assertNever } from "../jsutils/assert"; +import { + hashString, + combineHash, + accumulateHash, + UNINITIALIZED_HASH, +} from "../jsutils/selectionHash"; export type Context = Readonly<{ fragmentMap: FragmentMap; @@ -366,6 +372,14 @@ function completeSelections( for (const selection of next) { completeSelections(context, selection, depth + 1); } + + // Compute structural hashes and complex descendants bottom-up (children already completed above) + for (const selection of possibleSelections.values()) { + if (selection.structuralHash !== UNINITIALIZED_HASH) continue; // already hashed (shared selection) + selection.structuralHash = computeStructuralHash(selection); + computeHasDescendantsToResolve(selection); + } + return possibleSelections; } @@ -729,6 +743,7 @@ function copySelection( fieldQueue: [], experimentalAlias: selection.experimentalAlias, depth: selection.depth, + structuralHash: UNINITIALIZED_HASH, }; for (const [field, aliases] of selection.fields.entries()) { copy.fields.set(field, [...aliases]); @@ -756,8 +771,68 @@ function copySelection( return copy; } +/** + * Compute an order-independent structural hash of a PossibleSelection. + * 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 { + 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) + // 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)); + } + } + } + // Accumulate order-independently across field names + hash = accumulateHash(hash, fieldHash); + } + // TODO: fieldsWithDirectives + spreadsWithDirectives should also affect structural hash (only directives + arg names) + + return hash; +} + +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 }; + 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 b9e92879f..b67a89527 100644 --- a/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts +++ b/packages/apollo-forest-run/src/descriptor/resolvedSelection.ts @@ -24,10 +24,22 @@ import { valueFromASTUntyped } from "graphql"; import { equal } from "@wry/equality"; import { assert } from "../jsutils/assert"; import { createArgumentDefs } from "./possibleSelection"; +import { + hashString, + combineHash, + hashValue, + UNINITIALIZED_HASH, +} from "../jsutils/selectionHash"; 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 +60,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,59 +109,188 @@ 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 || selection.hasDescendantsToResolve) { + resolvedSelection = { + ...selection, + fieldQueue, + 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; + } map.set(typeName, resolvedSelection); } return resolvedSelection; } -export function resolvedSelectionsAreEqual( - a: ResolvedSelection, - b: ResolvedSelection, -) { - if (a === b) { - return true; +/** 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; + } + } } - 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; +} + +function computeResolvedHash( + operation: OperationDescriptor | undefined, + selection: ResolvedSelection, +): number { + let hash = selection.structuralHash; + + // 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) { + hash = combineHash(hash, hashString(argName)); + hash = combineHash(hash, hashValue(argVal)); + } + } + } } - if (a.skippedFields?.size !== b.skippedFields?.size) { - return false; + + // 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) { + 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)); + } + } + } + } + } } - assert(a.normalizedFields?.size === b.normalizedFields?.size); - const aNormalizedFields = a.normalizedFields?.entries() ?? EMPTY_ARRAY; - for (const [alias, aNormalized] of aNormalizedFields) { - const bNormalized = b.normalizedFields?.get(alias); - assert(aNormalized && bNormalized); - if (!fieldEntriesAreEqual(aNormalized, bNormalized)) { - return false; + // Mix in skipped fields + if (selection.skippedFields?.size) { + for (const field of selection.skippedFields) { + hash = combineHash(hash, hashString(field.dataKey)); } } - for (const aSkipped of a.skippedFields ?? EMPTY_ARRAY) { - if (!b.skippedFields?.has(aSkipped)) { - return false; + + // Mix in skipped spreads + if (selection.skippedSpreads?.size) { + for (const spread of selection.skippedSpreads) { + hash = combineHash(hash, hashString(spread.name)); } } - // 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 - return true; + + // 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 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)); + } + // 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)); + } + } + } + } + } + + return hash >>> 0; +} + +/** Lazily compute resolved hash on first cross-doc comparison */ +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 = operation ?? selectionOperations.get(selection); + const hash = computeResolvedHash(op, selection); + // 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; +} + +export function resolvedSelectionsAreEqual( + a: ResolvedSelection, + b: ResolvedSelection, +): boolean { + if (a === b) return true; + if (a.structuralHash !== b.structuralHash) return false; + return getResolvedHash(a) === getResolvedHash(b); } export function resolveNormalizedField( diff --git a/packages/apollo-forest-run/src/descriptor/types.ts b/packages/apollo-forest-run/src/descriptor/types.ts index 8c82b41cf..d48e63db6 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,10 @@ 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) }; 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 dbd562a8d..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,6 +2077,7 @@ function chunkPerField(env: DiffEnv, sourceChunk: ObjectChunk): ObjectChunk[] { fieldsWithSelections: selection.fieldsWithSelections?.includes(name) ? [name] : [], + structuralHash: UNINITIALIZED_HASH, }, ], ]); 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/__tests__/selectionHash.test.ts b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts new file mode 100644 index 000000000..551ff3605 --- /dev/null +++ b/packages/apollo-forest-run/src/jsutils/__tests__/selectionHash.test.ts @@ -0,0 +1,190 @@ +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("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 }; + // Both have same keys ("valueOf") so they hash the same by keys + expect(hashValue(a)).toBe(hashValue(b)); + }); + + 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 new file mode 100644 index 000000000..9e025ac70 --- /dev/null +++ b/packages/apollo-forest-run/src/jsutils/selectionHash.ts @@ -0,0 +1,90 @@ +/** + * 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; +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++) { + 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 combineHash(TYPE_STRING, hashString(val)); + case "number": { + f64Buf[0] = val; + return combineHash(TYPE_NUMBER, combineHash(u32Buf[0], u32Buf[1])); + } + case "boolean": + return combineHash(TYPE_BOOLEAN, val ? 1 : 0); + case "bigint": + return combineHash(TYPE_BIGINT, hashString(val.toString())); + default: { + if (Array.isArray(val)) { + let h = 0xa5a5a5a5; + for (let i = 0; i < val.length; i++) { + h = combineHash(h, hashValue(val[i])); + } + return h; + } + // 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; + let h = 0x5a5a5a5a; + for (const k of Object.keys(obj)) { + h = accumulateHash(h, combineHash(hashString(k), 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; +}