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')