diff --git a/.changeset/fix-fragment-pagination.md b/.changeset/fix-fragment-pagination.md
new file mode 100644
index 000000000..d5f284f92
--- /dev/null
+++ b/.changeset/fix-fragment-pagination.md
@@ -0,0 +1,8 @@
+---
+'houdini': patch
+'houdini-react': patch
+'houdini-svelte': patch
+'houdini-core': patch
+---
+
+fixed fragment pagination
diff --git a/.changeset/singlepage-cursor-stack.md b/.changeset/singlepage-cursor-stack.md
deleted file mode 100644
index 6ad9842b1..000000000
--- a/.changeset/singlepage-cursor-stack.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'houdini': minor
-'houdini-react': minor
----
-
-Fix SinglePage cursor pagination to use replace semantics instead of accumulating edges, and add cursor-stack support so forward-only APIs can navigate backward and backward-only APIs can navigate forward after they've seen the previous page.
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index a1ef4ddeb..3373419ae 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -9,7 +9,7 @@ jobs:
benchmark:
name: Benchmark
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 30
permissions:
contents: read
steps:
@@ -36,12 +36,18 @@ jobs:
# ── PR branch ────────────────────────────────────────────────────
- run: pnpm install --frozen-lockfile --prefer-offline
- - name: Benchmark PR branch
- run: BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.current.json
+ - name: Benchmark PR branch (3 runs)
+ run: |
+ for i in 1 2 3; do
+ BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.current.$i.json
+ done
+ node perf/merge.js /tmp/benchmark.current.1.json /tmp/benchmark.current.2.json /tmp/benchmark.current.3.json > /tmp/benchmark.current.json
# ── Base branch ──────────────────────────────────────────────────
- - name: Save benchmark suite from PR
- run: cp -r packages/houdini/src/runtime/cache/benchmarks /tmp/houdini-benchmarks
+ - name: Save benchmark suite and merge script from PR
+ run: |
+ cp -r packages/houdini/src/runtime/cache/benchmarks /tmp/houdini-benchmarks
+ cp perf/merge.js /tmp/houdini-merge.js
- name: Checkout base branch
run: git checkout ${{ github.base_ref }}
@@ -53,8 +59,12 @@ jobs:
- run: pnpm install --frozen-lockfile --prefer-offline
- - name: Benchmark base branch
- run: BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.baseline.json
+ - name: Benchmark base branch (3 runs)
+ run: |
+ for i in 1 2 3; do
+ BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.baseline.$i.json
+ done
+ node /tmp/houdini-merge.js /tmp/benchmark.baseline.1.json /tmp/benchmark.baseline.2.json /tmp/benchmark.baseline.3.json > /tmp/benchmark.baseline.json
# ── Compare ──────────────────────────────────────────────────────
- name: Checkout PR branch
diff --git a/e2e/kit/src/lib/utils/routes.ts b/e2e/kit/src/lib/utils/routes.ts
index ffa921ac7..cce63066c 100644
--- a/e2e/kit/src/lib/utils/routes.ts
+++ b/e2e/kit/src/lib/utils/routes.ts
@@ -69,6 +69,7 @@ export const routes = {
Pagination_fragment_bidirectional_cursor: '/pagination/fragment/bidirectional-cursor',
Pagination_fragment_offset: '/pagination/fragment/offset',
Pagination_fragment_required_arguments: '/pagination/fragment/required-arguments',
+ Pagination_fragment_forward_cursor_singlepage: '/pagination/fragment/forward-cursor-singlepage',
nested_argument_fragments: '/nested-argument-fragments',
nested_argument_fragments_masking: '/nested-argument-fragments-masking',
diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte
new file mode 100644
index 000000000..5c5211513
--- /dev/null
+++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {$fragmentResult.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+
+ {stringify($fragmentResult.pageInfo)}
+
+
+
+
diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts
new file mode 100644
index 000000000..23b350806
--- /dev/null
+++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts
@@ -0,0 +1,18 @@
+import type { PageLoad } from './$types'
+import { graphql } from '$houdini'
+
+const store = graphql(`
+ query UserFragmentForwardsCursorSinglePageQuery {
+ user(id: "1", snapshot: "pagination-fragment-forwards-cursor-singlepage-svelte") {
+ ...ForwardsCursorSinglePageFragment
+ }
+ }
+`)
+
+export const load: PageLoad = async (event) => {
+ await store.fetch({ event })
+
+ return {
+ UserFragmentForwardsCursorSinglePageQuery: store,
+ }
+}
diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts
new file mode 100644
index 000000000..ae911d8ea
--- /dev/null
+++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts
@@ -0,0 +1,39 @@
+import { test } from '@playwright/test'
+import { routes } from '../../../../lib/utils/routes.js'
+import {
+ expect_0_gql,
+ expect_1_gql,
+ expect_to_be,
+ expectToContain,
+ goto,
+} from '../../../../lib/utils/testsHelper.js'
+
+test.describe('forwards cursor fragment single page', () => {
+ test('loadNextPage replaces data', async ({ page }) => {
+ await goto(page, routes.Pagination_fragment_forward_cursor_singlepage)
+
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+ })
+})
diff --git a/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts b/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts
index 9aa7cace4..185a1c70f 100644
--- a/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts
+++ b/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts
@@ -3,6 +3,7 @@ import { routes } from '../../../../lib/utils/routes.js'
import {
expect_to_be,
expectToContain,
+ expect_0_gql,
expect_1_gql,
goto,
stringify,
@@ -29,8 +30,8 @@ test.describe('bidirectional cursor single page paginated query', () => {
/// Click on the next button
- // load the next page and wait for the response
- await expect_1_gql(page, 'button[id=next]')
+ // page 2 was the initial load — cache hit, no network request
+ await expect_0_gql(page, 'button[id=next]')
// there should be no previous page
await expectToContain(page, `"hasPreviousPage":true`)
@@ -87,8 +88,8 @@ test.describe('bidirectional cursor single page paginated query', () => {
/// Click on the previous button
- // load the previous page and wait for the response
- await expect_1_gql(page, 'button[id=previous]')
+ // page 2 was the initial load — cache hit, no network request
+ await expect_0_gql(page, 'button[id=previous]')
// make sure we got the new content
await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
@@ -100,7 +101,7 @@ test.describe('bidirectional cursor single page paginated query', () => {
/// Click on the previous button
- // load the previous page and wait for the response
+ // previousCursors now empty — use before cursor to fetch page 1
await expect_1_gql(page, 'button[id=previous]')
// make sure we got the new content
diff --git a/e2e/react/src/+index.jsx b/e2e/react/src/+index.jsx
index 27fad7b2d..b79ef3ddd 100644
--- a/e2e/react/src/+index.jsx
+++ b/e2e/react/src/+index.jsx
@@ -26,10 +26,6 @@ class ErrorBoundary extends React.Component {
return { hasError: true }
}
- componentDidCatch(error, info) {
- console.error('ErrorBoundary caught an error:', error, info)
- }
-
render() {
if (this.state.hasError) {
return Something went wrong.
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql
new file mode 100644
index 000000000..158191505
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql
@@ -0,0 +1,5 @@
+query FragmentCursorBackwardsSinglePageQuery {
+ user(id: "1", snapshot: "pagination-fragment-cursor-backwards-singlepage") {
+ ...FragmentCursorBackwardsSinglePageFragment
+ }
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx
new file mode 100644
index 000000000..f411d613c
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx
@@ -0,0 +1,45 @@
+import { graphql, useFragmentHandle } from '$houdini'
+import type { PageProps } from './$types'
+
+const fragment = graphql(`
+ fragment FragmentCursorBackwardsSinglePageFragment on User {
+ usersConnectionSnapshot(
+ snapshot: "pagination-fragment-cursor-backwards-singlepage"
+ last: 2
+ ) @paginate(mode: SinglePage) {
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+`)
+
+export default function ({ FragmentCursorBackwardsSinglePageQuery }: PageProps) {
+ const handle = useFragmentHandle(FragmentCursorBackwardsSinglePageQuery.user, fragment)
+
+ return (
+ <>
+
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+ {JSON.stringify(handle.pageInfo)}
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts
new file mode 100644
index 000000000..01f088896
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts
@@ -0,0 +1,41 @@
+import { test } from '@playwright/test'
+import { routes } from '~/utils/routes.js'
+import {
+ expect_to_be,
+ expectToContain,
+ expect_0_gql,
+ expect_1_gql,
+ goto,
+} from '~/utils/testsHelper.js'
+
+test.describe('backwards cursor fragment single page paginated query', () => {
+ test('loadPreviousPage replaces data then loadNextPage navigates forward', async ({ page }) => {
+ await goto(page, routes.pagination_fragment_cursor_backwards_singlepage)
+
+ await expect_to_be(page, 'Eddie Murphy, Clint Eastwood')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":false`)
+
+ await expect_1_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // "Will Smith, Harrison Ford" was fetched on the way back — served from cache.
+ await expect_0_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // Page 4 was the initial load — cache hit, no network request.
+ await expect_0_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Eddie Murphy, Clint Eastwood')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":false`)
+ })
+})
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql
new file mode 100644
index 000000000..5bce57040
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql
@@ -0,0 +1,5 @@
+query FragmentCursorBackwardsQuery {
+ user(id: "1", snapshot: "pagination-fragment-cursor-backwards") {
+ ...FragmentCursorBackwardsFragment
+ }
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx
new file mode 100644
index 000000000..106826f0b
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx
@@ -0,0 +1,38 @@
+import { graphql, useFragmentHandle } from '$houdini'
+import type { PageProps } from './$types'
+
+const fragment = graphql(`
+ fragment FragmentCursorBackwardsFragment on User {
+ usersConnectionSnapshot(snapshot: "pagination-fragment-cursor-backwards", last: 2) @paginate {
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+`)
+
+export default function ({ FragmentCursorBackwardsQuery }: PageProps) {
+ const handle = useFragmentHandle(FragmentCursorBackwardsQuery.user, fragment)
+
+ return (
+ <>
+
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+ {JSON.stringify(handle.pageInfo)}
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts b/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts
new file mode 100644
index 000000000..778a37e20
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts
@@ -0,0 +1,21 @@
+import { test } from '@playwright/test'
+import { routes } from '~/utils/routes.js'
+import { expect_to_be, expectToContain, expect_1_gql, goto } from '~/utils/testsHelper.js'
+
+test.describe('backwards cursor fragment paginated query', () => {
+ test('loadPreviousPage prepends data', async ({ page }) => {
+ await goto(page, routes.pagination_fragment_cursor_backwards)
+
+ await expect_to_be(page, 'Eddie Murphy, Clint Eastwood')
+ await expectToContain(page, `"hasPreviousPage":true`)
+
+ await expect_1_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood')
+
+ await expect_1_gql(page, 'button[id=previous]')
+ await expect_to_be(
+ page,
+ 'Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood'
+ )
+ })
+})
diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql
new file mode 100644
index 000000000..2497d4ee1
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql
@@ -0,0 +1,5 @@
+query FragmentSinglePageQuery {
+ user(id: "1", snapshot: "pagination-fragment-bidirectional-singlepage") {
+ ...UserConnectionSinglePageFragment
+ }
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx
new file mode 100644
index 000000000..765c9e090
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx
@@ -0,0 +1,42 @@
+import { graphql, useFragmentHandle } from '$houdini'
+import type { PageProps } from './$types'
+
+const fragment = graphql(`
+ fragment UserConnectionSinglePageFragment on User {
+ usersConnectionSnapshot(snapshot: "pagination-fragment-bidirectional-singlepage", first: 2) @paginate(mode: SinglePage) {
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+`)
+
+export default function ({ FragmentSinglePageQuery }: PageProps) {
+ const handle = useFragmentHandle(FragmentSinglePageQuery.user, fragment)
+
+ return (
+ <>
+
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+ {JSON.stringify(handle.pageInfo)}
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts
new file mode 100644
index 000000000..638e0c1e6
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts
@@ -0,0 +1,41 @@
+import { test } from '@playwright/test'
+import { routes } from '~/utils/routes.js'
+import {
+ expect_to_be,
+ expectToContain,
+ expect_0_gql,
+ expect_1_gql,
+ goto,
+} from '~/utils/testsHelper.js'
+
+test.describe('bidirectional cursor fragment single page paginated query', () => {
+ test('loadNextPage replaces data', async ({ page }) => {
+ await goto(page, routes.pagination_fragment_bidirectional_cursor_singlepage)
+
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // Page 2 was fetched on the way forward — the cache serves it without a network request.
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // Page 1 was the initial load — cache hit, no network request.
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+ })
+})
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql
new file mode 100644
index 000000000..279a646ea
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql
@@ -0,0 +1,5 @@
+query FragmentCursorForwardsSinglePageQuery {
+ user(id: "1", snapshot: "pagination-fragment-cursor-forwards-singlepage") {
+ ...FragmentCursorForwardsSinglePageFragment
+ }
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx
new file mode 100644
index 000000000..27d06e3af
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx
@@ -0,0 +1,45 @@
+import { graphql, useFragmentHandle } from '$houdini'
+import type { PageProps } from './$types'
+
+const fragment = graphql(`
+ fragment FragmentCursorForwardsSinglePageFragment on User {
+ usersConnectionSnapshot(
+ snapshot: "pagination-fragment-cursor-forwards-singlepage"
+ first: 2
+ ) @paginate(mode: SinglePage) {
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+`)
+
+export default function ({ FragmentCursorForwardsSinglePageQuery }: PageProps) {
+ const handle = useFragmentHandle(FragmentCursorForwardsSinglePageQuery.user, fragment)
+
+ return (
+ <>
+
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+ {JSON.stringify(handle.pageInfo)}
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts
new file mode 100644
index 000000000..d6c27e39d
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts
@@ -0,0 +1,41 @@
+import { test } from '@playwright/test'
+import { routes } from '~/utils/routes.js'
+import {
+ expect_to_be,
+ expectToContain,
+ expect_0_gql,
+ expect_1_gql,
+ goto,
+} from '~/utils/testsHelper.js'
+
+test.describe('forwards cursor fragment single page paginated query', () => {
+ test('loadNextPage replaces data', async ({ page }) => {
+ await goto(page, routes.pagination_fragment_cursor_forwards_singlepage)
+
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Will Smith, Harrison Ford')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // Page 2 was fetched on the way forward — the cache serves it without a network request.
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
+ await expectToContain(page, `"hasPreviousPage":true`)
+ await expectToContain(page, `"hasNextPage":true`)
+
+ // Page 1 was the initial load — cache hit, no network request.
+ await expect_0_gql(page, 'button[id=previous]')
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasPreviousPage":false`)
+ await expectToContain(page, `"hasNextPage":true`)
+ })
+})
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql
new file mode 100644
index 000000000..d338bcba7
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql
@@ -0,0 +1,5 @@
+query FragmentCursorForwardsQuery {
+ user(id: "1", snapshot: "pagination-fragment-cursor-forwards") {
+ ...FragmentCursorForwardsFragment
+ }
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx
new file mode 100644
index 000000000..1664652e6
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx
@@ -0,0 +1,38 @@
+import { graphql, useFragmentHandle } from '$houdini'
+import type { PageProps } from './$types'
+
+const fragment = graphql(`
+ fragment FragmentCursorForwardsFragment on User {
+ usersConnectionSnapshot(snapshot: "pagination-fragment-cursor-forwards", first: 2) @paginate {
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+`)
+
+export default function ({ FragmentCursorForwardsQuery }: PageProps) {
+ const handle = useFragmentHandle(FragmentCursorForwardsQuery.user, fragment)
+
+ return (
+ <>
+
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')}
+
+
+ {JSON.stringify(handle.pageInfo)}
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts b/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts
new file mode 100644
index 000000000..3a57caa24
--- /dev/null
+++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts
@@ -0,0 +1,21 @@
+import { test } from '@playwright/test'
+import { routes } from '~/utils/routes.js'
+import { expect_to_be, expectToContain, expect_1_gql, goto } from '~/utils/testsHelper.js'
+
+test.describe('forwards cursor fragment paginated query', () => {
+ test('loadNextPage appends data', async ({ page }) => {
+ await goto(page, routes.pagination_fragment_cursor_forwards)
+
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson')
+ await expectToContain(page, `"hasNextPage":true`)
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks')
+
+ await expect_1_gql(page, 'button[id=next]')
+ await expect_to_be(
+ page,
+ 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford'
+ )
+ })
+})
diff --git a/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts
index c74e2e2e9..2f8fd451e 100644
--- a/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts
+++ b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts
@@ -1,6 +1,6 @@
import { test } from '@playwright/test'
import { routes } from '~/utils/routes.js'
-import { expect_to_be, expectToContain, expect_1_gql, goto } from '~/utils/testsHelper.js'
+import { expect_to_be, expectToContain, expect_0_gql, expect_1_gql, goto } from '~/utils/testsHelper.js'
test.describe('bidirectional cursor single page paginated query', () => {
test('forwards three times then backwards three times', async ({ page }) => {
@@ -25,12 +25,14 @@ test.describe('bidirectional cursor single page paginated query', () => {
await expectToContain(page, `"hasPreviousPage":true`)
await expectToContain(page, `"hasNextPage":false`)
- await expect_1_gql(page, 'button[id=previous]')
+ // Pages 3 and 2 were fetched going forward — the cache serves them without a network
+ // request. Page 1 came from the initial query (different cache key), so it needs one.
+ await expect_0_gql(page, 'button[id=previous]')
await expect_to_be(page, 'Will Smith, Harrison Ford')
await expectToContain(page, `"hasPreviousPage":true`)
await expectToContain(page, `"hasNextPage":true`)
- await expect_1_gql(page, 'button[id=previous]')
+ await expect_0_gql(page, 'button[id=previous]')
await expect_to_be(page, 'Morgan Freeman, Tom Hanks')
await expectToContain(page, `"hasPreviousPage":true`)
await expectToContain(page, `"hasNextPage":true`)
diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts
index c32b0b43d..2adcd8d7c 100644
--- a/e2e/react/src/utils/routes.ts
+++ b/e2e/react/src/utils/routes.ts
@@ -17,6 +17,11 @@ export const routes = {
pagination_query_bidirectional_cursor_singlepage: '/pagination/query/connection-bidirectional-singlepage',
pagination_query_forwards_cursor_singlepage: '/pagination/query/connection-forwards-singlepage',
pagination_query_backwards_cursor_singlepage: '/pagination/query/connection-backwards-singlepage',
+ pagination_fragment_bidirectional_cursor_singlepage: '/pagination/fragment/connection-bidirectional-singlepage',
+ pagination_fragment_cursor_forwards: '/pagination/fragment/connection-forwards',
+ pagination_fragment_cursor_backwards: '/pagination/fragment/connection-backwards',
+ pagination_fragment_cursor_forwards_singlepage: '/pagination/fragment/connection-forwards-singlepage',
+ pagination_fragment_cursor_backwards_singlepage: '/pagination/fragment/connection-backwards-singlepage',
pagination_query_offset_variable: '/pagination/query/offset-variable/2',
optimistic_keys: '/optimistic-keys',
node_plugin: '/node-plugin',
diff --git a/packages/houdini-core/plugin/documents/artifacts/merge.go b/packages/houdini-core/plugin/documents/artifacts/merge.go
index c1f134c42..b816bedd8 100644
--- a/packages/houdini-core/plugin/documents/artifacts/merge.go
+++ b/packages/houdini-core/plugin/documents/artifacts/merge.go
@@ -238,9 +238,11 @@ func (c *fieldCollection) Add(
// mask directives on the spread win, otherwise we fall back to the project's
// defaultFragmentMasking setting
childHidden := c.DefaultMask
+ explicitlyUnmasked := false
for _, directive := range selection.Directives {
if directive.Name == graphql.DisableMaskDirective {
childHidden = false
+ explicitlyUnmasked = true
break
}
if directive.Name == graphql.EnableMaskDirective {
@@ -249,8 +251,9 @@ func (c *fieldCollection) Add(
}
}
// a spread that is itself in a hidden context (eg inside a masked fragment)
- // keeps its fields hidden no matter what
- if external || selection.Internal {
+ // keeps its fields hidden no matter what, unless the spread explicitly opts out
+ // of masking via @mask_disable (e.g. generated pagination queries for fragments).
+ if (external || selection.Internal) && !explicitlyUnmasked {
childHidden = true
}
diff --git a/packages/houdini-core/plugin/documents/artifacts/selection.go b/packages/houdini-core/plugin/documents/artifacts/selection.go
index bec53ad4e..e6be098e0 100644
--- a/packages/houdini-core/plugin/documents/artifacts/selection.go
+++ b/packages/houdini-core/plugin/documents/artifacts/selection.go
@@ -877,17 +877,23 @@ func stringifySelection(
%s}`, resultBuilder.String(), indent)
}
-func keyField(field *collected.Selection, paginatedMode *string) string {
+func keyField(field *collected.Selection, paginatedMode *string, _ string) string {
+ // Pagination args are stripped and ::paginated suffix applied only for Infinite mode,
+ // where all pages accumulate under one stable key.
+ // SinglePage pagination (both query and fragment) uses distinct per-cursor cache keys
+ // so the cache can serve back-navigation without a network request.
+ useStableKey := paginatedMode != nil && *paginatedMode == graphql.PaginationModeInfinite
+ isSinglePage := paginatedMode != nil && *paginatedMode == graphql.PaginationModeSinglePage
+
if len(field.Arguments) == 0 {
paginationSuffix := ""
- if paginatedMode != nil && *paginatedMode == graphql.PaginationModeInfinite {
+ if useStableKey {
paginationSuffix = "::paginated"
}
return `"` + *field.Alias + paginationSuffix + `"`
}
- // if we are generating the key for a paginated field then we need to strip away
- // the pagination arguments
+ // strip pagination args when using a stable key
args := []*collected.Argument{}
for _, arg := range field.Arguments {
paginationArgs := map[string]bool{
@@ -898,18 +904,52 @@ func keyField(field *collected.Selection, paginatedMode *string) string {
"limit": true,
"offset": true,
}
- if _, ok := paginationArgs[arg.Name]; ok && paginatedMode != nil &&
- *paginatedMode == graphql.PaginationModeInfinite {
+ if _, ok := paginationArgs[arg.Name]; ok && useStableKey {
continue
}
- // if we got this far then we can add the arg
a := *arg
args = append(args, &a)
}
+ // For SinglePage, the initial parent query and the pagination query must share the same
+ // cache key for the first page so backward navigation finds the cached data.
+ // The pagination query always includes all four cursor args; expand the parent query's
+ // key to match by adding any missing cursor args as null in canonical order
+ // (first, after, last, before) followed by the remaining user-defined args.
+ if isSinglePage {
+ cursorNames := []string{"first", "after", "last", "before"}
+ existing := map[string]*collected.Argument{}
+ for _, arg := range args {
+ existing[arg.Name] = arg
+ }
+
+ var ordered []*collected.Argument
+ for _, name := range cursorNames {
+ if arg, ok := existing[name]; ok {
+ ordered = append(ordered, arg)
+ } else {
+ // missing cursor arg — add it as null so the key matches the pagination query
+ ordered = append(ordered, &collected.Argument{Name: name, Value: nil})
+ }
+ }
+ for _, arg := range args {
+ isCursor := false
+ for _, name := range cursorNames {
+ if arg.Name == name {
+ isCursor = true
+ break
+ }
+ }
+ if !isCursor {
+ ordered = append(ordered, arg)
+ }
+ }
+ args = ordered
+ }
+
paginationSuffix := ""
- if paginatedMode != nil && *paginatedMode == graphql.PaginationModeInfinite {
+ if useStableKey {
paginationSuffix = "::paginated"
}
@@ -941,9 +981,11 @@ func stringifyFieldSelection(
// figure out the pagination state
var paginatedMode *string
+ paginatedTargetType := "Query"
if selection.List != nil {
if selection.List.Paginated {
paginatedMode = &selection.List.Mode
+ paginatedTargetType = selection.List.TargetType
}
updates = []string{}
// SinglePage pagination uses replace semantics — never accumulate edges
@@ -1284,7 +1326,7 @@ func stringifyFieldSelection(
indent4,
selection.FieldType,
indent4,
- keyField(selection, paginatedMode),
+ keyField(selection, paginatedMode, paginatedTargetType),
updateStr,
nullable,
directives,
diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go
index 73e6cc0af..36313a7e2 100644
--- a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go
+++ b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go
@@ -806,7 +806,7 @@ export type TestQuery$artifact = typeof artifact
"fields": {
"usersByCursor": {
"type": "UserConnection",
- "keyRaw": "usersByCursor(after: $after, before: $before, first: $first, last: $last)",
+ "keyRaw": "usersByCursor(first: $first, after: $after, last: $last, before: $before)",
"directives": [{
"name": "paginate",
diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go
index 2da7821b4..bac95a275 100644
--- a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go
+++ b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go
@@ -330,7 +330,7 @@ export type PaginatedFragment$artifact = typeof artifact
},
},
{
- Name: "pagination arguments stays in key as its a SinglePage Mode",
+ Name: "pagination arguments included in key for SinglePage Mode (per-cursor keys, no ::paginated)",
Input: []string{
`
fragment PaginatedFragment on User {
@@ -398,7 +398,7 @@ export type PaginatedFragment$artifact = typeof artifact
"friendsByCursor": {
"type": "UserConnection",
- "keyRaw": "friendsByCursor(filter: \"hello\", first: 10)",
+ "keyRaw": "friendsByCursor(first: 10, after: null, last: null, before: null, filter: \"hello\")",
"nullable": true,
"directives": [{
@@ -1835,7 +1835,7 @@ export type TestQuery$artifact = typeof artifact
"moves": {
"type": "SpeciesMoveConnection",
- "keyRaw": "moves(after: $after, first: $first)",
+ "keyRaw": "moves(first: $first, after: $after, last: null, before: null)",
"directives": [{
"name": "paginate",
diff --git a/packages/houdini-core/plugin/lists/paginationDocuments.go b/packages/houdini-core/plugin/lists/paginationDocuments.go
index 493413e03..afa94b59f 100644
--- a/packages/houdini-core/plugin/lists/paginationDocuments.go
+++ b/packages/houdini-core/plugin/lists/paginationDocuments.go
@@ -911,6 +911,17 @@ func processFragmentPagination(
return 0, err
}
+ // mark the fragment spread with @mask_disable so the cache reader exposes its
+ // fields through the abstract type wrapper (node() returns an interface, and
+ // external fragment spreads are masked by default; we need them visible here).
+ err = ctx.db.ExecStatement(ctx.insertSelectionDirective, map[string]any{
+ "selection": fragmentSpreadID,
+ "directive": graphql.DisableMaskDirective,
+ })
+ if err != nil {
+ return 0, err
+ }
+
// add resolve query arguments (keys)
for _, key := range list.Keys {
// create variable value for resolve query argument
diff --git a/packages/houdini-core/plugin/lists/paginationDocuments_test.go b/packages/houdini-core/plugin/lists/paginationDocuments_test.go
index 6888a0181..a883e146d 100644
--- a/packages/houdini-core/plugin/lists/paginationDocuments_test.go
+++ b/packages/houdini-core/plugin/lists/paginationDocuments_test.go
@@ -549,7 +549,7 @@ func TestPaginationDocumentGeneration(t *testing.T) {
fmt.Sprintf(`
query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) {
node(id: $id) {
- ...Friends_paginated_c9Zhk @with(first: $first, after: $after, before: $before, last: $last)
+ ...Friends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last)
__typename
id
}
@@ -606,7 +606,7 @@ func TestPaginationDocumentGeneration(t *testing.T) {
fmt.Sprintf(`
query %s($limit: Int = 10, $offset: Int, $title: String!) @dedupe(match: Variables) {
legend(title: $title) {
- ...Believers_paginated_1uyQEt @with(limit: $limit, offset: $offset)
+ ...Believers_paginated_1uyQEt @mask_disable @with(limit: $limit, offset: $offset)
__typename
title
}
@@ -784,7 +784,7 @@ func TestPaginationDocumentGeneration(t *testing.T) {
fmt.Sprintf(`
query %s($first: Int = 2, $after: String, $before: String, $last: Int, $id: ID!, $snapshot: String!) @dedupe(match: Variables) {
node(id: $id) {
- ...UserFriends_paginated_SAvn1 @with(first: $first, after: $after, before: $before, last: $last, snapshot: $snapshot)
+ ...UserFriends_paginated_SAvn1 @mask_disable @with(first: $first, after: $after, before: $before, last: $last, snapshot: $snapshot)
__typename
id
}
@@ -861,7 +861,7 @@ func TestPaginationDocumentGeneration(t *testing.T) {
fmt.Sprintf(`
query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) {
node(id: $id) {
- ...Friends_paginated_c9Zhk @with(first: $first, after: $after, before: $before, last: $last)
+ ...Friends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last)
__typename
id
}
diff --git a/packages/houdini-react/plugin/runtime.go b/packages/houdini-react/plugin/runtime.go
index b3ce2c839..7df78d5f3 100644
--- a/packages/houdini-react/plugin/runtime.go
+++ b/packages/houdini-react/plugin/runtime.go
@@ -241,8 +241,9 @@ type hookSpec struct {
kind string // "query", "mutation", "subscription", or "fragment"
marker string // text immediately before which overloads are inserted
preamble string // extra import line to prepend (empty if not needed)
- imports func(name string) string
- overloads func(name string) string
+ // paginationQuery is the name of the pagination query document for paginated fragments, or ""
+ imports func(name string, paginationQuery string) string
+ overloads func(name string, paginationQuery string) string
passthrough string // generic overload inserted last, bridges concrete overloads to the implementation
}
@@ -254,10 +255,10 @@ var hookSpecs = []hookSpec{
file: "useQuery.ts",
kind: "query",
marker: "export function useQuery<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useQuery(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): %s$result\n",
name, name, name,
@@ -269,10 +270,10 @@ var hookSpecs = []hookSpec{
file: "useQueryHandle.ts",
kind: "query",
marker: "export function useQueryHandle<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useQueryHandle(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): DocumentHandle<%s$artifact, %s$result, GraphQLVariables>\n",
name, name, name, name,
@@ -284,10 +285,10 @@ var hookSpecs = []hookSpec{
file: "useFragment.ts",
kind: "fragment",
marker: "export function useFragment<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$data, %s$artifact } from '$houdini/artifacts/%s'\n", name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useFragment(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact }): %s$data\n"+
"export function useFragment(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact }): %s$data | null\n",
@@ -301,14 +302,26 @@ var hookSpecs = []hookSpec{
file: "useFragmentHandle.ts",
kind: "fragment",
marker: "export function useFragmentHandle<",
- imports: func(name string) string {
- return fmt.Sprintf("import type { %s$data, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
+ // For paginated fragments, import the pagination query artifact too.
+ imports: func(name string, paginationQuery string) string {
+ base := fmt.Sprintf("import type { %s$data, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
+ if paginationQuery != "" {
+ base += fmt.Sprintf("import type { %s$artifact } from '$houdini/artifacts/%s'\n", paginationQuery, paginationQuery)
+ }
+ return base
},
- // DocumentHandle's first type param must extend QueryArtifact; for fragments that
- // have no refetchArtifact we use the base QueryArtifact (already imported by the source).
- // Both non-null and nullable reference overloads use the same non-null data type since
- // DocumentHandle._Data extends GraphQLObject (not null).
- overloads: func(name string) string {
+ // For paginated fragments, return DocumentHandle typed with the pagination query artifact
+ // so TypeScript exposes loadNext/loadPrevious/pageInfo on the returned handle.
+ // For non-paginated fragments, fall back to DocumentHandle.
+ overloads: func(name string, paginationQuery string) string {
+ if paginationQuery != "" {
+ return fmt.Sprintf(
+ "export function useFragmentHandle(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact; refetchArtifact?: %s$artifact }): DocumentHandle<%s$artifact, %s$data, %s$input>\n"+
+ "export function useFragmentHandle(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact; refetchArtifact?: %s$artifact }): DocumentHandle<%s$artifact, %s$data, %s$input>\n",
+ fragmentKeyLiteral, name, name, paginationQuery, paginationQuery, name, name,
+ fragmentKeyLiteral, name, name, paginationQuery, paginationQuery, name, name,
+ )
+ }
return fmt.Sprintf(
"export function useFragmentHandle(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact }): DocumentHandle\n"+
"export function useFragmentHandle(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact }): DocumentHandle\n",
@@ -322,10 +335,10 @@ var hookSpecs = []hookSpec{
file: "useMutation.ts",
kind: "mutation",
marker: "export function useMutation<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input, %s$optimistic } from '$houdini/artifacts/%s'\n", name, name, name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useMutation(document: { artifact: %s$artifact }): [MutationHandler<%s$result, %s$input, %s$optimistic>, boolean]\n",
name, name, name, name,
@@ -337,10 +350,10 @@ var hookSpecs = []hookSpec{
file: "useSubscription.ts",
kind: "subscription",
marker: "export function useSubscription<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useSubscription(document: { artifact: %s$artifact }, variables?: %s$input): %s$result\n",
name, name, name,
@@ -352,10 +365,10 @@ var hookSpecs = []hookSpec{
file: "useSubscriptionHandle.ts",
kind: "subscription",
marker: "export function useSubscriptionHandle<",
- imports: func(name string) string {
+ imports: func(name string, _ string) string {
return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name)
},
- overloads: func(name string) string {
+ overloads: func(name string, _ string) string {
return fmt.Sprintf(
"export function useSubscriptionHandle(document: { artifact: %s$artifact }, variables?: %s$input): SubscriptionHandle<%s$result, %s$input>\n",
name, name, name, name,
@@ -444,6 +457,25 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) {
return nil, err
}
+ // Build the set of visible fragments that are paginated. We detect pagination
+ // via discovered_lists (populated during Validate) rather than looking for
+ // a pre-existing _Pagination_Query document, because GenerateRuntime runs
+ // concurrently with GenerateDocuments and the document may not exist yet.
+ paginatedFragments := map[string]string{}
+ err = p.DB.StepQuery(ctx, `
+ SELECT DISTINCT d.name
+ FROM documents d
+ JOIN discovered_lists dl ON dl.document = d.id
+ WHERE d.visible = 1 AND d.kind = 'fragment'
+ AND dl.paginate IS NOT NULL
+ `, nil, func(q plugins.Row) {
+ name := q.ColumnText(0)
+ paginatedFragments[name] = name + "_Pagination_Query"
+ })
+ if err != nil {
+ return nil, err
+ }
+
var changed []string
for _, spec := range hookSpecs {
names := docsByKind[spec.kind]
@@ -476,13 +508,13 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) {
top.WriteString("\n")
}
for _, name := range names {
- top.WriteString(spec.imports(name))
+ top.WriteString(spec.imports(name, paginatedFragments[name]))
}
top.WriteString("\n")
var before strings.Builder
for _, name := range names {
- before.WriteString(spec.overloads(name))
+ before.WriteString(spec.overloads(name, paginatedFragments[name]))
}
if spec.passthrough != "" {
before.WriteString(spec.passthrough + "\n")
diff --git a/packages/houdini-react/plugin/runtime_test.go b/packages/houdini-react/plugin/runtime_test.go
index 5bc09208c..48cb4d7fd 100644
--- a/packages/houdini-react/plugin/runtime_test.go
+++ b/packages/houdini-react/plugin/runtime_test.go
@@ -160,9 +160,33 @@ func TestUpdateIndexFiles(t *testing.T) {
func TestUpdateHookFiles(t *testing.T) {
tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{
Schema: `
- type Query { id: ID }
+ type Query {
+ id: ID
+ node(id: ID!): Node
+ }
type Mutation { id: ID }
type Subscription { id: ID }
+
+ interface Node { id: ID! }
+ type User implements Node {
+ id: ID!
+ firstName: String!
+ friends(first: Int, after: String, last: Int, before: String): UserConnection!
+ }
+ type UserConnection {
+ pageInfo: PageInfo!
+ edges: [UserEdge!]!
+ }
+ type UserEdge {
+ cursor: String!
+ node: User!
+ }
+ type PageInfo {
+ hasNextPage: Boolean!
+ hasPreviousPage: Boolean!
+ startCursor: String
+ endCursor: String
+ }
`,
SetupTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) {
@@ -266,6 +290,49 @@ func TestUpdateHookFiles(t *testing.T) {
},
},
},
+ {
+ Name: "injects useFragmentHandle overloads for non-paginated fragment",
+ Pass: true,
+ Input: []string{
+ `fragment MyFragment on Query { id }`,
+ },
+ Extra: map[string]any{
+ "stubs": map[string]string{
+ "useFragmentHandle.ts": "import type { QueryArtifact } from 'houdini/runtime'\n\nexport function useFragmentHandle<_A>(ref: any, doc: any): any {}\n",
+ },
+ "expected": map[string]string{
+ "useFragmentHandle.ts": "import type { MyFragment$data, MyFragment$artifact, MyFragment$input } from '$houdini/artifacts/MyFragment'\n" +
+ "\n" +
+ "import type { QueryArtifact } from 'houdini/runtime'\n\n" +
+ "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyFragment: any } }, document: { artifact: MyFragment$artifact }): DocumentHandle\n" +
+ "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyFragment: any } } | null, document: { artifact: MyFragment$artifact }): DocumentHandle\n" +
+ "export function useFragmentHandle<_Artifact extends FragmentArtifact, _Data extends GraphQLObject, _ReferenceType extends {}, _PaginationArtifact extends QueryArtifact, _Input extends GraphQLVariables>(reference: _Data | { \" $fragments\": _ReferenceType } | null, document: { artifact: _Artifact; refetchArtifact?: _PaginationArtifact }): DocumentHandle<_PaginationArtifact, _Data, _Input>\n" +
+ "export function useFragmentHandle<_A>(ref: any, doc: any): any {}\n",
+ },
+ },
+ },
+ {
+ Name: "injects useFragmentHandle overloads with pagination query artifact for paginated fragment",
+ Pass: true,
+ Input: []string{
+ `fragment MyPaginatedFragment on User { friends(first: 2) @paginate { edges { node { firstName } } } }`,
+ },
+ Extra: map[string]any{
+ "stubs": map[string]string{
+ "useFragmentHandle.ts": "import type { QueryArtifact } from 'houdini/runtime'\n\nexport function useFragmentHandle<_A>(ref: any, doc: any): any {}\n",
+ },
+ "expected": map[string]string{
+ "useFragmentHandle.ts": "import type { MyPaginatedFragment$data, MyPaginatedFragment$artifact, MyPaginatedFragment$input } from '$houdini/artifacts/MyPaginatedFragment'\n" +
+ "import type { MyPaginatedFragment_Pagination_Query$artifact } from '$houdini/artifacts/MyPaginatedFragment_Pagination_Query'\n" +
+ "\n" +
+ "import type { QueryArtifact } from 'houdini/runtime'\n\n" +
+ "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyPaginatedFragment: any } }, document: { artifact: MyPaginatedFragment$artifact; refetchArtifact?: MyPaginatedFragment_Pagination_Query$artifact }): DocumentHandle\n" +
+ "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyPaginatedFragment: any } } | null, document: { artifact: MyPaginatedFragment$artifact; refetchArtifact?: MyPaginatedFragment_Pagination_Query$artifact }): DocumentHandle\n" +
+ "export function useFragmentHandle<_Artifact extends FragmentArtifact, _Data extends GraphQLObject, _ReferenceType extends {}, _PaginationArtifact extends QueryArtifact, _Input extends GraphQLVariables>(reference: _Data | { \" $fragments\": _ReferenceType } | null, document: { artifact: _Artifact; refetchArtifact?: _PaginationArtifact }): DocumentHandle<_PaginationArtifact, _Data, _Input>\n" +
+ "export function useFragmentHandle<_A>(ref: any, doc: any): any {}\n",
+ },
+ },
+ },
{
Name: "skips files not present in plugin runtime dir",
Pass: true,
diff --git a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts
index 24bb39a52..4d7b27b5c 100644
--- a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts
+++ b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts
@@ -1,12 +1,15 @@
+import { extractPageInfo, cursorHandlers, offsetHandlers } from 'houdini/runtime'
import type {
GraphQLObject,
FragmentArtifact,
QueryArtifact,
GraphQLVariables,
+ FetchFn,
} from 'houdini/runtime'
+import * as React from 'react'
-import { useDocumentHandle, type DocumentHandle } from './useDocumentHandle.js'
-import { useDocumentStore } from './useDocumentStore.js'
+import { useClient, useSession } from '../routing/Router.js'
+import type { DocumentHandle } from './useDocumentHandle.js'
import { fragmentReference, useFragment } from './useFragment.js'
// useFragmentHandle is just like useFragment except it also returns an imperative handle
@@ -22,25 +25,167 @@ export function useFragmentHandle<
document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact }
): any {
// get the fragment values
- const data = useFragment<_Data, _ReferenceType, _Input>(reference, document)
+ const fragmentData = useFragment<_Data, _ReferenceType, _Input>(reference, document)
// look at the fragment reference to get the variables
const { variables } = fragmentReference<_Data, _Input, _ReferenceType>(reference, document)
- // use the pagination fragment for meta data if it exists.
- // if we pass this a fragment artifact, it won't add any data
- const [handleValue, handleObserver] = useDocumentStore<_Data, _Input>({
- artifact: document.refetchArtifact ?? document.artifact,
- })
- const handle = useDocumentHandle<_PaginationArtifact, _Data, _Input>({
- observer: handleObserver,
- storeValue: handleValue,
- artifact: document.refetchArtifact ?? document.artifact,
- })
+ const client = useClient()
+ const [session] = useSession()
+
+ const [forwardPending, setForwardPending] = React.useState(false)
+ const [backwardPending, setBackwardPending] = React.useState(false)
+
+ // Stable cursor stacks for SinglePage pagination — must survive re-renders
+ const previousCursorsRef = React.useRef<(string | null)[]>([])
+ const nextCursorsRef = React.useRef<(string | null)[]>([])
+
+ const refetchArtifact = document.refetchArtifact as QueryArtifact | undefined
+ const refetchPath = refetchArtifact?.refetch?.path
+
+ // Dedicated observer for pagination queries — separate from the fragment observer.
+ // cursorHandlers derives entity variables (e.g. { id }) and artifact defaults
+ // automatically via the type config, so no manual variable extraction is needed here.
+ const paginationObserver = React.useMemo(() => {
+ if (!refetchArtifact?.refetch?.paginated) return null
+ return client.observe<_Data, _Input>({ artifact: refetchArtifact })
+ }, [refetchArtifact?.name])
+
+ // Subscribe to the pagination observer so React re-renders whenever a new page is fetched
+ // or served from cache (CacheOrNetwork). The fragment store subscription only watches the
+ // initial page's cache key; the observer is the live source of truth for SinglePage
+ // pagination where each page lives at its own per-cursor cache key.
+ const subscribeToObserver = React.useCallback(
+ (onChange: () => void) => {
+ if (!paginationObserver) return () => {}
+ return paginationObserver.subscribe(onChange)
+ },
+ [paginationObserver]
+ )
+ const getObserverSnapshot = React.useCallback(
+ () => paginationObserver?.state.data ?? null,
+ [paginationObserver]
+ )
+ const paginationData = React.useSyncExternalStore(
+ subscribeToObserver,
+ getObserverSnapshot,
+ getObserverSnapshot
+ )
+
+ // Extract entity-level data from the pagination query response. For Node targetType
+ // the response is { node: EntityData }; we take the first root field to handle any type.
+ // Guard against partial cache hits (artifact has partial:true): only use the entity once the
+ // paginated connection field at refetch.path[0] is actually present in the response.
+ const paginationEntityData = React.useMemo<_Data | null>(() => {
+ if (!paginationData || !refetchArtifact?.selection?.fields) return null
+ const rootField = Object.keys(refetchArtifact.selection.fields)[0]
+ if (!rootField) return null
+ const entity = (paginationData as any)[rootField]
+ if (!entity) return null
+ const path = refetchArtifact.refetch?.path
+ if (path && path.length > 0 && (entity as any)[path[0]] == null) {
+ return null
+ }
+ return entity as _Data
+ }, [paginationData, refetchArtifact])
+
+ const isSinglePage = refetchArtifact?.refetch?.mode === 'SinglePage'
+
+ // For SinglePage: use the pagination observer's entity data (each page has its own
+ // cache key) once a page fetch has landed. For Infinite: always use fragmentData,
+ // which reads accumulated pages from cache via the fragment's cache subscription.
+ const displayData =
+ isSinglePage && paginationEntityData !== null ? paginationEntityData : fragmentData
+
+ const wrapLoad = <_Result>(
+ setLoading: (val: boolean) => void,
+ fn: (value: any) => Promise<_Result>
+ ) => {
+ return async (value: any) => {
+ setLoading(true)
+ let err: Error | null = null
+ let result: _Result | null = null
+ try {
+ result = await fn(value)
+ } catch (e) {
+ err = e as Error
+ }
+ setLoading(false)
+ if (err && err.name !== 'AbortError') throw err
+ return result
+ }
+ }
+
+ const handle = React.useMemo(() => {
+ if (!refetchArtifact?.refetch?.paginated || !paginationObserver) return null
+
+ const fetchFn: FetchFn<_Data, _Input> = (args) => {
+ return paginationObserver.send({ ...args, session })
+ }
+
+ const fetchUpdate = (args: any, updates: string[]) => {
+ return paginationObserver.send({
+ ...args,
+ cacheParams: {
+ ...args?.cacheParams,
+ disableSubscriptions: true,
+ applyUpdates: updates,
+ },
+ session,
+ })
+ }
+
+ if (refetchArtifact.refetch!.method === 'cursor') {
+ const handlers = cursorHandlers<_Data, _Input>({
+ artifact: refetchArtifact,
+ getState: () => displayData as _Data | null,
+ // Use the observer's own variable state so cursor history is preserved
+ // across page navigations without manual tracking in the hook.
+ getVariables: () =>
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
+ fetch: fetchFn,
+ fetchUpdate,
+ getSession: async () => session,
+ previousCursors: previousCursorsRef.current,
+ nextCursors: nextCursorsRef.current,
+ })
+
+ return {
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
+ loadNextPending: forwardPending,
+ loadPrevious: wrapLoad(setBackwardPending, handlers.loadPreviousPage),
+ loadPreviousPending: backwardPending,
+ pageInfo: refetchPath
+ ? extractPageInfo(displayData as GraphQLObject, refetchPath)
+ : null,
+ }
+ }
+
+ if (refetchArtifact.refetch!.method === 'offset') {
+ const handlers = offsetHandlers({
+ artifact: refetchArtifact,
+ getState: () => displayData as _Data | null,
+ getVariables: () =>
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
+ storeName: refetchArtifact.name,
+ fetch: fetchFn,
+ fetchUpdate: async (args: any, updates = ['append']) =>
+ fetchUpdate(args, updates) as any,
+ getSession: async () => session,
+ })
+
+ return {
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
+ loadNextPending: forwardPending,
+ }
+ }
+
+ return null
+ }, [refetchArtifact, paginationObserver, displayData, session, forwardPending, backwardPending])
return {
...handle,
variables,
- data,
+ data: displayData,
}
}
diff --git a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts
index 91a17c821..7b8d4aa7c 100644
--- a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts
+++ b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts
@@ -4,6 +4,7 @@ import { keyFieldsForType } from 'houdini/runtime'
import { siteURL } from 'houdini/runtime'
import { extractPageInfo } from 'houdini/runtime'
import { cursorHandlers, offsetHandlers } from 'houdini/runtime'
+import { fragmentKey } from 'houdini/runtime'
import type {
CachePolicies,
FragmentArtifact,
@@ -14,7 +15,6 @@ import type {
PageInfo,
CursorHandlers,
GraphQLVariables,
- fragmentKey,
} from 'houdini/runtime'
import { CompiledFragmentKind } from 'houdini/runtime'
import type { Readable, Subscriber } from 'svelte/store'
@@ -80,6 +80,14 @@ export class BasePaginatedFragmentStore<
}
}
+// Keyed by ":" so reactive re-invocations of get() don't reset cursor history.
+type _SinglePageState = {
+ paginationStore: DocumentStore
+ previousCursors: (string | null)[]
+ nextCursors: (string | null)[]
+}
+const _singlePageStateCache = new Map()
+
// both cursor paginated stores add a page info to their subscribe
export class FragmentStoreCursor<
_Data extends GraphQLObject,
@@ -94,18 +102,74 @@ export class FragmentStoreCursor<
})
const store = base.get(initialValue)
- // generate the pagination handlers
- const paginationStore = getClient().observe<_Data, _Input>({
- artifact: this.paginationArtifact,
- initialValue: store.initialValue,
- })
+ const isSinglePage = this.paginationArtifact.refetch?.mode === 'SinglePage'
+
+ let paginationStore: DocumentStore<_Data, _Input>
+ let previousCursors: (string | null)[]
+ let nextCursors: (string | null)[]
+
+ if (isSinglePage) {
+ const parent = (initialValue as any)?.[fragmentKey]?.values?.[this.artifact.name]
+ ?.parent
+ const stateKey = parent ? `${this.paginationArtifact.name}:${parent}` : null
+ const cached = stateKey ? _singlePageStateCache.get(stateKey) : null
+
+ if (cached) {
+ paginationStore = cached.paginationStore
+ previousCursors = cached.previousCursors
+ nextCursors = cached.nextCursors
+ } else {
+ paginationStore = getClient().observe<_Data, _Input>({
+ artifact: this.paginationArtifact,
+ initialValue: store.initialValue,
+ })
+ previousCursors = []
+ nextCursors = []
+ if (stateKey) {
+ _singlePageStateCache.set(stateKey, {
+ paginationStore,
+ previousCursors,
+ nextCursors,
+ })
+ }
+ }
+ } else {
+ paginationStore = getClient().observe<_Data, _Input>({
+ artifact: this.paginationArtifact,
+ initialValue: store.initialValue,
+ })
+ previousCursors = []
+ nextCursors = []
+ }
+
+ // First key of paginationArtifact.selection.fields is the query-level root (e.g. "user").
+ // initialValue is fragment-level data with no such wrapper, so wrapped is null until
+ // the first paginated fetch completes.
+ const rootField = isSinglePage
+ ? Object.keys(this.paginationArtifact.selection.fields ?? {})[0]
+ : null
+
+ const getPaginationEntity = (): _Data | null => {
+ if (!isSinglePage || !rootField) return null
+ const $pagination = get(paginationStore)
+ if (!$pagination.data) return null
+ const wrapped = ($pagination.data as any)?.[rootField]
+ if (!wrapped) return null
+ return wrapped as _Data
+ }
const handlers = this.storeHandlers(
paginationStore,
store.initialValue,
- () => get(store),
- // the variables that are needed for this query are the store's values and the ids
- () => store.variables as NonNullable<_Input>
+ () => getPaginationEntity() ?? get(store),
+ () => {
+ if (!isSinglePage) return store.variables as NonNullable<_Input>
+ const paginationVars = get(paginationStore).variables
+ if (paginationVars) return paginationVars as NonNullable<_Input>
+ return store.variables as NonNullable<_Input>
+ },
+ previousCursors,
+ nextCursors
)
const subscribe = (
@@ -117,10 +181,17 @@ export class FragmentStoreCursor<
| undefined
): (() => void) => {
const combined = derived([store, paginationStore], ([$parent, $pagination]) => {
+ let currentData: _Data | null
+ if (isSinglePage && rootField) {
+ const wrapped = ($pagination.data as any)?.[rootField]
+ currentData = wrapped ? (wrapped as _Data) : $parent
+ } else {
+ currentData = $parent
+ }
return {
...$pagination,
- data: $parent,
- pageInfo: extractPageInfo($parent, this.paginationArtifact.refetch!.path),
+ data: currentData,
+ pageInfo: extractPageInfo(currentData, this.paginationArtifact.refetch!.path),
} as FragmentPaginatedResult<_Data, { pageInfo: PageInfo }>
})
@@ -131,8 +202,6 @@ export class FragmentStoreCursor<
kind: CompiledFragmentKind,
subscribe: subscribe,
fetch: handlers.fetch,
-
- // add the pagination handlers
loadNextPage: handlers.loadNextPage,
loadPreviousPage: handlers.loadPreviousPage,
}
@@ -142,7 +211,9 @@ export class FragmentStoreCursor<
observer: DocumentStore<_Data, _Input>,
_initialValue: _Data | null,
getState: () => _Data | null,
- getVariables: () => NonNullable<_Input>
+ getVariables: () => NonNullable<_Input>,
+ previousCursors?: (string | null)[],
+ nextCursors?: (string | null)[]
): CursorHandlers<_Data, _Input> {
return cursorHandlers<_Data, _Input>({
getState,
@@ -151,12 +222,19 @@ export class FragmentStoreCursor<
fetchUpdate: async (args, updates) => {
await initClient()
+ // undefined entity vars would shadow the id cursorHandlers resolved via getVariables()
+ const entityVars = Object.fromEntries(
+ Object.entries(this.queryVariables(getState) as any).filter(
+ ([, v]) => v !== undefined
+ )
+ ) as _Input
+
return observer.send({
session: await getSession(),
...args,
variables: {
...args?.variables,
- ...this.queryVariables(getState),
+ ...entityVars,
},
cacheParams: {
applyUpdates: updates,
@@ -167,19 +245,27 @@ export class FragmentStoreCursor<
fetch: async (args) => {
await initClient()
+ const entityVars = Object.fromEntries(
+ Object.entries(this.queryVariables(getState) as any).filter(
+ ([, v]) => v !== undefined
+ )
+ ) as _Input
+
+ const resolvedVars = { ...args?.variables, ...entityVars }
+
return await observer.send({
session: await getSession(),
...args,
- variables: {
- ...args?.variables,
- ...this.queryVariables(getState),
- },
+ variables: resolvedVars,
+ policy: args?.policy,
cacheParams: {
disableSubscriptions: true,
},
})
},
getSession,
+ previousCursors,
+ nextCursors,
})
}
}
diff --git a/packages/houdini/src/runtime/cache/stuff.ts b/packages/houdini/src/runtime/cache/stuff.ts
index de2e38290..e6794ac1a 100644
--- a/packages/houdini/src/runtime/cache/stuff.ts
+++ b/packages/houdini/src/runtime/cache/stuff.ts
@@ -25,7 +25,7 @@ export function evaluateKey(key: string, variables: Record | null =
// look up the variable and add the result (varName starts with a $)
const value = variables?.[varName.slice(1)]
- evaluated += typeof value !== 'undefined' ? JSON.stringify(value) : 'undefined'
+ evaluated += JSON.stringify(value ?? null)
// clear the variable name accumulator
varName = ''
diff --git a/packages/houdini/src/runtime/cache/tests/keys.test.ts b/packages/houdini/src/runtime/cache/tests/keys.test.ts
index b94567d02..b78067e40 100644
--- a/packages/houdini/src/runtime/cache/tests/keys.test.ts
+++ b/packages/houdini/src/runtime/cache/tests/keys.test.ts
@@ -24,7 +24,7 @@ describe('key evaluation', () => {
{
title: 'undefined variable',
key: 'fieldName(foo: $bar)',
- expected: 'fieldName(foo: undefined)',
+ expected: 'fieldName(foo: null)',
},
]
diff --git a/packages/houdini/src/runtime/pagination.ts b/packages/houdini/src/runtime/pagination.ts
index e86bc50ea..ab0652597 100644
--- a/packages/houdini/src/runtime/pagination.ts
+++ b/packages/houdini/src/runtime/pagination.ts
@@ -1,6 +1,7 @@
import { deepEquals } from './deepEquals.js'
import type { SendParams } from './documentStore.js'
import { countPage, extractPageInfo, missingPageSizeError } from './pageInfo.js'
+import { defaultConfigValues, getCurrentConfig, keyFieldsForType } from './config.js'
import { CachePolicy, DataSource } from './types.js'
import type {
CursorHandlers,
@@ -34,6 +35,24 @@ export function cursorHandlers<
previousCursors?: (string | null)[]
nextCursors?: (string | null)[]
}): CursorHandlers<_Data, _Input> {
+ const targetType = artifact.refetch?.targetType
+
+ // Derive entity variables from the type config (e.g. { id: "..." } for Node).
+ // This mirrors what Svelte's queryVariables() does, so fragment pagination callers
+ // don't have to extract entity IDs manually.
+ const getEntityVars = (): Record => {
+ if (!targetType || targetType === 'Query') return {}
+ const config = defaultConfigValues(getCurrentConfig())
+ const typeConfig = config.types?.[targetType]
+ const state = getState()
+ if (!state) return {}
+ if (typeConfig?.resolve?.arguments) {
+ return (typeConfig.resolve.arguments(state) as Record) ?? {}
+ }
+ const keys = keyFieldsForType(config, targetType)
+ return Object.fromEntries(keys.map((key) => [key, (state as any)[key]]))
+ }
+
// dry up the page-loading logic
const loadPage = async ({
pageSizeVar,
@@ -50,8 +69,11 @@ export function cursorHandlers<
fetch?: typeof globalThis.fetch
where: 'start' | 'end'
}) => {
- // build up the variables to pass to the query
+ // build up the variables to pass to the query, layering in order of precedence:
+ // artifact defaults < entity vars < caller-supplied vars < page-specific cursor args
const loadVariables: _Input = {
+ ...(artifact.input?.defaults ?? {}),
+ ...getEntityVars(),
...getVariables(),
...input,
}
@@ -64,13 +86,17 @@ export function cursorHandlers<
// Get the Pagination Mode
const isSinglePage = artifact.refetch?.mode === 'SinglePage'
+ // SinglePage pagination uses per-cursor cache keys, so CacheOrNetwork naturally serves
+ // back-navigation from cache. Infinite mode appends pages, so always fetch fresh.
+ const policy = isSinglePage ? artifact.policy : CachePolicy.NetworkOnly
+
// send the query
return (isSinglePage ? parentFetch : parentFetchUpdate)(
{
variables: loadVariables,
fetch,
metadata,
- policy: isSinglePage ? artifact.policy : CachePolicy.NetworkOnly,
+ policy,
session: await getSession(),
},
isSinglePage ? [] : [where === 'start' ? 'prepend' : 'append']
@@ -125,6 +151,25 @@ export function cursorHandlers<
})
}
+ // Bidirectional SinglePage: if the user went backward and hasn't caught back up,
+ // re-issue the saved backward query (cache hit) instead of doing a fresh forward fetch.
+ if (isSinglePage && direction === 'both' && nextCursors.length > 0) {
+ const beforeCursor = nextCursors.pop()!
+ return loadPage({
+ pageSizeVar: 'last',
+ functionName: 'loadNextPage',
+ input: {
+ before: beforeCursor,
+ last: first ?? artifact.refetch!.pageSize,
+ first: null,
+ after: null,
+ } as unknown as _Input,
+ fetch,
+ metadata,
+ where: 'start',
+ })
+ }
+
// we need to find the connection object holding the current page info
const currentPageInfo = getPageInfo()
// if there is no next page, we're done
@@ -140,8 +185,9 @@ export function cursorHandlers<
})
}
- // Forward-only SinglePage: push current 'after' so we can navigate back later
- if (isSinglePage && direction === 'forward') {
+ // SinglePage (forward or both): push the current 'after' cursor so
+ // loadPreviousPage can re-issue the same forward query (cache hit on back-nav).
+ if (isSinglePage && (direction === 'forward' || direction === 'both')) {
previousCursors.push((getVariables() as any)?.after ?? null)
}
@@ -177,19 +223,12 @@ export function cursorHandlers<
const isSinglePage = artifact.refetch?.mode === 'SinglePage'
const direction = artifact.refetch?.direction
- // Forward-only SinglePage: use previousCursors stack to re-issue a forward query
- if (isSinglePage && direction === 'forward') {
- if (previousCursors.length === 0) {
- return Promise.resolve({
- data: getState(),
- errors: null,
- fetching: false,
- partial: false,
- stale: false,
- source: DataSource.Cache,
- variables: getVariables(),
- })
- }
+ // SinglePage (forward or both): use previousCursors stack to re-issue a forward query.
+ if (
+ isSinglePage &&
+ (direction === 'forward' || direction === 'both') &&
+ previousCursors.length > 0
+ ) {
const afterCursor = previousCursors.pop()!
return loadPage({
pageSizeVar: 'first',
@@ -205,6 +244,17 @@ export function cursorHandlers<
where: 'end',
})
}
+ if (isSinglePage && direction === 'forward') {
+ return Promise.resolve({
+ data: getState(),
+ errors: null,
+ fetching: false,
+ partial: false,
+ stale: false,
+ source: DataSource.Cache,
+ variables: getVariables(),
+ })
+ }
// we need to find the connection object holding the current page info
const currentPageInfo = getPageInfo()
@@ -222,9 +272,18 @@ export function cursorHandlers<
})
}
- // Backward-only SinglePage: push current 'before' so we can navigate forward later
- if (isSinglePage && direction === 'backward') {
- nextCursors.push((getVariables() as any)?.before ?? null)
+ // Backward-only or bidirectional SinglePage going backward: push the current
+ // 'before' cursor so loadNextPage can re-issue the same backward query (cache hit).
+ // Skip if the current page was forward-loaded (has first/after set) — in that case
+ // loadNextPage's natural forward nav will reach the same cache key via endCursor.
+ const pVars = getVariables() as any
+ const isForwardPage = pVars?.first != null || pVars?.after != null
+ if (
+ isSinglePage &&
+ (direction === 'backward' || direction === 'both') &&
+ !isForwardPage
+ ) {
+ nextCursors.push(pVars?.before ?? null)
}
// only specify the page count if we're given one
diff --git a/perf/merge.js b/perf/merge.js
new file mode 100644
index 000000000..f298574b1
--- /dev/null
+++ b/perf/merge.js
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+// Merge multiple vitest benchmark JSON runs into one by taking the median hz
+// (and median rme) per benchmark. This smooths out single-run scheduler noise.
+//
+// Usage:
+// node perf/merge.js run1.json run2.json [run3.json ...] > merged.json
+
+import { readFileSync } from 'node:fs'
+
+const files = process.argv.slice(2)
+if (files.length < 2) {
+ console.error('usage: merge.js run1.json run2.json [run3.json ...]')
+ process.exit(1)
+}
+
+const reports = files.map((f) => JSON.parse(readFileSync(f, 'utf8')))
+
+function median(values) {
+ const sorted = [...values].sort((a, b) => a - b)
+ const mid = Math.floor(sorted.length / 2)
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
+}
+
+// Build a flat map of name → [bench across runs] for each report
+function flatBenchmarks(report) {
+ const map = new Map()
+ for (const file of report.files) {
+ for (const group of file.groups) {
+ for (const bench of group.benchmarks) {
+ const key = `${group.fullName} > ${bench.name}`
+ map.set(key, bench)
+ }
+ }
+ }
+ return map
+}
+
+const maps = reports.map(flatBenchmarks)
+
+// Use first report as the structural template, overwrite hz/rme with medians
+const merged = JSON.parse(JSON.stringify(reports[0]))
+
+for (const file of merged.files) {
+ for (const group of file.groups) {
+ for (const bench of group.benchmarks) {
+ const key = `${group.fullName} > ${bench.name}`
+ const allHz = maps.map((m) => m.get(key)?.hz).filter((v) => v != null)
+ const allRme = maps.map((m) => m.get(key)?.rme).filter((v) => v != null)
+ if (allHz.length > 0) bench.hz = median(allHz)
+ if (allRme.length > 0) bench.rme = median(allRme)
+ }
+ }
+}
+
+process.stdout.write(JSON.stringify(merged, null, 4) + '\n')