From 879dcd9341d83680192c403fef1e8fb01a89923f Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Sun, 21 Jun 2026 21:49:58 -0700 Subject: [PATCH 1/4] Fix React @loading pages failing to hydrate on the client --- .changeset/react-loading-hydration.md | 7 + CLAUDE.md | 2 + .../src/routes/loading-interactive/+page.gql | 5 + .../src/routes/loading-interactive/+page.tsx | 25 + .../src/routes/loading-interactive/test.ts | 23 + .../loading-paginated-fragment/+page.gql | 6 + .../loading-paginated-fragment/+page.tsx | 54 +++ .../routes/loading-paginated-fragment/test.ts | 19 + e2e/react/src/utils/routes.ts | 2 + .../plugin/documents/artifacts/selection.go | 7 +- .../artifacts/selection_loading_test.go | 448 ++---------------- .../plugin/lists/paginationDocuments.go | 6 +- .../plugin/lists/paginationDocuments_test.go | 160 +++++++ packages/houdini-react/package/vite/index.ts | 22 +- packages/houdini-react/plugin/generate.go | 8 +- packages/houdini-react/runtime/hydration.tsx | 11 + .../houdini-react/runtime/routing/Router.tsx | 25 +- 17 files changed, 405 insertions(+), 425 deletions(-) create mode 100644 .changeset/react-loading-hydration.md create mode 100644 e2e/react/src/routes/loading-interactive/+page.gql create mode 100644 e2e/react/src/routes/loading-interactive/+page.tsx create mode 100644 e2e/react/src/routes/loading-interactive/test.ts create mode 100644 e2e/react/src/routes/loading-paginated-fragment/+page.gql create mode 100644 e2e/react/src/routes/loading-paginated-fragment/+page.tsx create mode 100644 e2e/react/src/routes/loading-paginated-fragment/test.ts diff --git a/.changeset/react-loading-hydration.md b/.changeset/react-loading-hydration.md new file mode 100644 index 000000000..267910ac6 --- /dev/null +++ b/.changeset/react-loading-hydration.md @@ -0,0 +1,7 @@ +--- +'houdini-core': patch +'houdini': patch +'houdini-react': patch +--- + +Fix React `@loading` pages failing to hydrate on the client, which left them non-interactive and prevented paginated fragments spread on an `@loading` query from rendering or paginating once the data resolved. diff --git a/CLAUDE.md b/CLAUDE.md index cc47b5084..c6c0b1049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,8 @@ Canonical example: `packages/houdini-core/plugin/validate_test.go`. TypeScript test helpers: `testConfig()` / `testConfigFile()` in `packages/houdini/src/test/index.ts`. +**Updating a golden artifact**: the artifact table tests (e.g. `selection_*_test.go`) compare the whole generated artifact with `require.Equal`, so when a codegen change shifts the expected output, do NOT hand-edit the golden surgically — that's error-prone and has caused confusion. Instead: replace the entire expected value for that case with a placeholder (`tests.Dedent(\`PLACEHOLDER\`)`), run the test, copy the `actual:` string from the failure into place, then re-run and eyeball the diff to confirm the shape changed only the way you intended. Two tips: the failure prints `actual` already Go-escaped, so it can be pasted as a plain double-quoted string (no `Dedent`/backtick juggling needed); and when a change ripples across several cases, capture each `actual:` and splice it in (anchor on the case's unique hash if keys repeat) rather than editing by hand. + ## React route typing Per-route TypeScript typing (which `params` and `search` a route accepts, the `RouteHrefs` union, scalar resolution) lives in **one** place: `packages/houdini-react/runtime/routes.ts`. It derives everything from the generated manifest's shape via `typeof rawManifest`, and exports `RouteHrefs`, `ParamsForRoute`, `SearchForRoute`, `NavTarget`, and `Goto`. diff --git a/e2e/react/src/routes/loading-interactive/+page.gql b/e2e/react/src/routes/loading-interactive/+page.gql new file mode 100644 index 000000000..77ea5a4d3 --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/+page.gql @@ -0,0 +1,5 @@ +query LoadingInteractiveQuery @loading { + user(id: "1", snapshot: "loading-interactive", delay: 400) { + name + } +} diff --git a/e2e/react/src/routes/loading-interactive/+page.tsx b/e2e/react/src/routes/loading-interactive/+page.tsx new file mode 100644 index 000000000..fbed0bc8c --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/+page.tsx @@ -0,0 +1,25 @@ +import { isPending } from '$houdini' +import * as React from 'react' + +import type { PageProps } from './$types' + +// The simplest possible reproduction of issue #1408's underlying cause: a page whose +// query is marked @loading renders its loading frame inside a streaming Suspense +// boundary. Once the data resolves the real markup is swapped in by the server stream, +// but the client must still hydrate it so the page is interactive. This button proves +// that: if hydration never commits, the counter stays at 0 no matter how many times +// it is clicked. +export default function ({ LoadingInteractiveQuery }: PageProps) { + const user = LoadingInteractiveQuery.user + const [count, setCount] = React.useState(0) + + return ( + <> +
{isPending(user.name) ? 'loading' : user.name}
+
{count}
+ + + ) +} diff --git a/e2e/react/src/routes/loading-interactive/test.ts b/e2e/react/src/routes/loading-interactive/test.ts new file mode 100644 index 000000000..a23f8c63e --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/test.ts @@ -0,0 +1,23 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, goto } from '~/utils/testsHelper.js' + +test.describe('@loading page hydration', () => { + // issue #1408: a page whose query is marked @loading streams its loading frame and then + // has the resolved markup swapped in by the server stream. The client must still hydrate + // the page so it is interactive. A button that increments a counter is the minimal proof: + // if hydration never commits, clicking it does nothing. + test('the page is interactive after the loading state resolves', async ({ page }) => { + await goto(page, routes.loading_interactive) + + // the real data lands once the @loading query resolves + await expect_to_be(page, 'Bruce Willis', '#name') + + // clicking the button must update the counter, which only happens if React hydrated + await page.click('button[id=increment]') + await expect_to_be(page, '1', '#count') + + await page.click('button[id=increment]') + await expect_to_be(page, '2', '#count') + }) +}) diff --git a/e2e/react/src/routes/loading-paginated-fragment/+page.gql b/e2e/react/src/routes/loading-paginated-fragment/+page.gql new file mode 100644 index 000000000..1b550dbdf --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/+page.gql @@ -0,0 +1,6 @@ +query LoadingFragmentQuery @loading { + user(id: "1", snapshot: "loading-paginated-fragment", delay: 400) { + name + ...LoadingFragmentList + } +} diff --git a/e2e/react/src/routes/loading-paginated-fragment/+page.tsx b/e2e/react/src/routes/loading-paginated-fragment/+page.tsx new file mode 100644 index 000000000..cc7226315 --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/+page.tsx @@ -0,0 +1,54 @@ +import { graphql, isPending, useFragmentHandle } from '$houdini' + +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment LoadingFragmentList on User { + usersConnectionSnapshot(snapshot: "loading-paginated-fragment", first: 2) @paginate { + edges { + node { + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +// Rendered as a child so it mounts *during* the parent query's @loading frame +// (no if-guard). This is the exact scenario from issue #1408. +function FriendsList({ user }: { user: any }) { + const handle = useFragmentHandle(user, fragment) + + const edges = handle.data?.usersConnectionSnapshot?.edges + const names = Array.isArray(edges) + ? edges + .map(({ node }) => node?.name) + .filter((name) => typeof name === 'string') + .join(', ') + : '' + + return ( + <> +
{names}
+
{JSON.stringify(handle.pageInfo)}
+ + + ) +} + +export default function ({ LoadingFragmentQuery }: PageProps) { + const user = LoadingFragmentQuery.user + + return ( + <> +
{isPending(user.name) ? 'loading' : user.name}
+ + + ) +} diff --git a/e2e/react/src/routes/loading-paginated-fragment/test.ts b/e2e/react/src/routes/loading-paginated-fragment/test.ts new file mode 100644 index 000000000..ea290bde9 --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/test.ts @@ -0,0 +1,19 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expect_1_gql, goto } from '~/utils/testsHelper.js' + +test.describe('paginated fragment under an @loading query', () => { + // issue #1408: a paginated fragment spread on an @loading query mounts during the + // loading frame. once the parent query resolves the list should appear and paginate, + // without the user having to guard the component behind an `if (!loading)` check. + test('renders and paginates after loading resolves (no guard)', async ({ page }) => { + await goto(page, routes.loading_paginated_fragment) + + // first page arrives with the parent query once @loading resolves + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + + // and pagination keeps working from there + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') + }) +}) diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts index 5d6cda829..5fdab71b6 100644 --- a/e2e/react/src/utils/routes.ts +++ b/e2e/react/src/utils/routes.ts @@ -23,6 +23,8 @@ export const routes = { 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', + loading_paginated_fragment: '/loading-paginated-fragment', + loading_interactive: '/loading-interactive', refetchable_fragment: '/refetchable-fragment', refetchable_fragment_custom: '/refetchable-fragment-custom', optimistic_keys: '/optimistic-keys', diff --git a/packages/houdini-core/plugin/documents/artifacts/selection.go b/packages/houdini-core/plugin/documents/artifacts/selection.go index 3ebc9ee39..60b60d60f 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection.go @@ -655,7 +655,12 @@ func stringifySelection( loadingTypes := []string{} for _, selection := range selections { - hasLoading := false + // inherit the cascading loading state (a document-level @loading, or a parent + // field's @loading(cascade: true)) so that fragment spreads participate in the + // loading state just like fields do. without this a spread under a global @loading + // is omitted from the loading-state selection entirely. mirrors the field path, + // which seeds hasLoading from forceLoading. + hasLoading := forceLoading for _, directive := range selection.Directives { switch directive.Name { case graphql.LoadingDirective: diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go index 20686e5c5..5af398dd2 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go @@ -1146,179 +1146,7 @@ export type Query$artifact = typeof artifact }`, }, Extra: map[string]any{ - "Query": tests.Dedent( - `import type { LoadingType } from "houdini/runtime"; -const artifact = { - "name": "Query", - "kind": "HoudiniQuery", - "hash": "75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a", - "raw": ` + "`" + `query Query { - entities { - ... on User { - firstName - __typename - id - } - ... on Cat { - name - __typename - id - } - __typename - } -} -` + "`" + `, - - "rootType": "Query", - "stripVariables": [] as Array, - - "selection": { - "fields": { - "entities": { - "type": "Entity", - "keyRaw": "entities", - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - }, - "abstractFields": { - "fields": { - "Cat": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - "id": { - "type": "ID", - "keyRaw": "id", - "loading": { - "kind": "value", - }, - }, - "name": { - "type": "String", - "keyRaw": "name", - "loading": { - "kind": "value", - }, - "visible": true, - }, - }, - "User": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - "firstName": { - "type": "String", - "keyRaw": "firstName", - "loading": { - "kind": "value", - }, - "visible": true, - }, - "id": { - "type": "ID", - "keyRaw": "id", - "loading": { - "kind": "value", - }, - }, - }, - }, - - "typeMap": {}, - }, - }, - - "loading": { - "kind": "continue", - "list": { - "depth": 1, - "count": 3, - }, - }, - "abstract": true, - "visible": true, - }, - }, - }, - - "pluginData": {}, - "enableLoadingState": "global", - "policy": "CacheOrNetwork", - "partial": false -} as const - -export default artifact - -export type Query = { - readonly "input"?: Query$input; - readonly "result": Query$result | undefined; -}; - -export type Query$result = { - readonly entities: ({} & (({ - readonly name: string; - readonly id: string; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -} | { - readonly entities: ({} & (({ - readonly name: LoadingType; - readonly id: LoadingType; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: LoadingType; - readonly id: LoadingType; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -}; - -export type Query$input = null | undefined; - -export type Query$unmasked = { - readonly entities: ({} & (({ - readonly id: string; - readonly name: string; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -}; - -export type Query$artifact = typeof artifact - -"HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a"`, - ), + "Query": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"Query\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a\",\n \"raw\": `query Query {\n entities {\n ... on User {\n firstName\n __typename\n id\n }\n ... on Cat {\n name\n __typename\n id\n }\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"entities\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"entities\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"Cat\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"name\": {\n \"type\": \"String\",\n \"keyRaw\": \"name\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"typeMap\": {},\n },\n\n \"loadingTypes\": [\"Cat\", \"User\"],\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n \"list\": {\n \"depth\": 1,\n \"count\": 3,\n },\n },\n \"abstract\": true,\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"global\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type Query = {\n\treadonly \"input\"?: Query$input;\n\treadonly \"result\": Query$result | undefined;\n};\n\nexport type Query$result = {\n\treadonly entities: ({} & (({\n\t\treadonly name: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n} | {\n\treadonly entities: ({} & (({\n\t\treadonly name: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$input = null | undefined;\n\nexport type Query$unmasked = {\n\treadonly entities: ({} & (({\n\t\treadonly id: string;\n\t\treadonly name: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$artifact = typeof artifact\n\n\"HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a\"", }, }, { @@ -1345,252 +1173,34 @@ export type Query$artifact = typeof artifact `, }, Extra: map[string]any{ - "Query": tests.Dedent( - `import type { LoadingType } from "houdini/runtime"; -const artifact = { - "name": "Query", - "kind": "HoudiniQuery", - "hash": "8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877", - "raw": ` + "`" + `query Query { - entities { - ... on User { - firstName - __typename - id - } - ... on Cat { - name - __typename - id - } - __typename - } - b: entities { - ... on User { - firstName - __typename - id - } - __typename - } -} -` + "`" + `, - - "rootType": "Query", - "stripVariables": [] as Array, - - "selection": { - "fields": { - "b": { - "type": "Entity", - "keyRaw": "b", - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, - }, - "abstractFields": { - "fields": { - "User": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, - "firstName": { - "type": "String", - "keyRaw": "firstName", - "visible": true, - }, - "id": { - "type": "ID", - "keyRaw": "id", - }, - }, - }, - - "typeMap": {}, - }, - }, - - "abstract": true, - "visible": true, - }, - - "entities": { - "type": "Entity", - "keyRaw": "entities", - - "directives": [{ - "name": "loading", - "arguments": { - "cascade": { - "kind": "BooleanValue", - "value": true - } - } - }], - - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - }, - "abstractFields": { - "fields": { - "Cat": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - "id": { - "type": "ID", - "keyRaw": "id", - "loading": { - "kind": "value", - }, - }, - "name": { - "type": "String", - "keyRaw": "name", - "loading": { - "kind": "value", - }, - "visible": true, - }, - }, - "User": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "loading": { - "kind": "value", - }, - }, - "firstName": { - "type": "String", - "keyRaw": "firstName", - "loading": { - "kind": "value", - }, - "visible": true, - }, - "id": { - "type": "ID", - "keyRaw": "id", - "loading": { - "kind": "value", - }, - }, - }, - }, - - "typeMap": {}, - }, - }, - - "loading": { - "kind": "continue", - "list": { - "depth": 1, - "count": 3, - }, - }, - "abstract": true, - "visible": true, - }, - }, - }, - - "pluginData": {}, - "enableLoadingState": "local", - "policy": "CacheOrNetwork", - "partial": false -} as const - -export default artifact - -export type Query = { - readonly "input"?: Query$input; - readonly "result": Query$result | undefined; -}; - -export type Query$result = { - readonly entities: ({} & (({ - readonly name: string; - readonly id: string; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; - readonly b: ({} & (({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -} | { - readonly entities: ({} & (({ - readonly name: LoadingType; - readonly id: LoadingType; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: LoadingType; - readonly id: LoadingType; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -}; - -export type Query$input = null | undefined; - -export type Query$unmasked = { - readonly b: ({} & (({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; - readonly entities: ({} & (({ - readonly id: string; - readonly name: string; - readonly __typename: "Cat"; - }) | ({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })))[]; -}; - -export type Query$artifact = typeof artifact - -"HoudiniHash=8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877"`, - ), + "Query": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"Query\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877\",\n \"raw\": `query Query {\n entities {\n ... on User {\n firstName\n __typename\n id\n }\n ... on Cat {\n name\n __typename\n id\n }\n __typename\n }\n b: entities {\n ... on User {\n firstName\n __typename\n id\n }\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"b\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"b\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n },\n },\n },\n\n \"typeMap\": {},\n },\n },\n\n \"abstract\": true,\n \"visible\": true,\n },\n\n \"entities\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"entities\",\n\n \"directives\": [{\n \"name\": \"loading\",\n \"arguments\": {\n \"cascade\": {\n \"kind\": \"BooleanValue\",\n \"value\": true\n }\n }\n }],\n\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"Cat\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"name\": {\n \"type\": \"String\",\n \"keyRaw\": \"name\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"typeMap\": {},\n },\n\n \"loadingTypes\": [\"Cat\", \"User\"],\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n \"list\": {\n \"depth\": 1,\n \"count\": 3,\n },\n },\n \"abstract\": true,\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"local\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type Query = {\n\treadonly \"input\"?: Query$input;\n\treadonly \"result\": Query$result | undefined;\n};\n\nexport type Query$result = {\n\treadonly entities: ({} & (({\n\t\treadonly name: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n\treadonly b: ({} & (({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n} | {\n\treadonly entities: ({} & (({\n\t\treadonly name: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$input = null | undefined;\n\nexport type Query$unmasked = {\n\treadonly b: ({} & (({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n\treadonly entities: ({} & (({\n\t\treadonly id: string;\n\t\treadonly name: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$artifact = typeof artifact\n\n\"HoudiniHash=8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877\"", + }, + }, + { + // a document-level @loading must cascade onto fragment spreads, not just + // fields. without this the spread is omitted from the loading-state + // selection (no "loading": true), so the runtime can't bind it during the + // loading frame. + Name: "global @loading cascades to fragment spreads", + Pass: true, + Input: []string{ + ` + query GlobalLoadingSpreadQuery @loading { + monkeys { + ...ConnectionInfo + } + } + `, + ` + fragment ConnectionInfo on AnimalConnection { + pageInfo { + hasNextPage + } + } + `, + }, + Extra: map[string]any{ + "GlobalLoadingSpreadQuery": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"GlobalLoadingSpreadQuery\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370\",\n \"raw\": `fragment ConnectionInfo on AnimalConnection {\n pageInfo {\n hasNextPage\n __typename\n }\n __typename\n}\n\nquery GlobalLoadingSpreadQuery {\n monkeys {\n ...ConnectionInfo\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"monkeys\": {\n \"type\": \"MonkeyConnection\",\n \"keyRaw\": \"monkeys\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"pageInfo\": {\n \"type\": \"PageInfo\",\n \"keyRaw\": \"pageInfo\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"hasNextPage\": {\n \"type\": \"Boolean\",\n \"keyRaw\": \"hasNextPage\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n },\n },\n\n \"fragments\": {\n \"ConnectionInfo\": {\n \"arguments\": {},\n \"loading\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"global\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type GlobalLoadingSpreadQuery = {\n\treadonly \"input\"?: GlobalLoadingSpreadQuery$input;\n\treadonly \"result\": GlobalLoadingSpreadQuery$result | undefined;\n};\n\nexport type GlobalLoadingSpreadQuery$result = {\n\treadonly monkeys: {\n\t\treadonly \" $fragments\": {\n\t\t\tConnectionInfo: {};\n\t\t};\n\t};\n} | {\n\treadonly monkeys: {\n\t\treadonly \" $fragments\": {\n\t\t\tConnectionInfo: {};\n\t\t};\n\t};\n};\n\nexport type GlobalLoadingSpreadQuery$input = null | undefined;\n\nexport type GlobalLoadingSpreadQuery$unmasked = {\n\treadonly monkeys: {\n\t\treadonly __typename: \"MonkeyConnection\";\n\t\treadonly pageInfo: {\n\t\t\treadonly __typename: \"PageInfo\";\n\t\t\treadonly hasNextPage: boolean;\n\t\t};\n\t};\n};\n\nexport type GlobalLoadingSpreadQuery$artifact = typeof artifact\n\n\"HoudiniHash=f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370\"", }, }, }, diff --git a/packages/houdini-core/plugin/lists/paginationDocuments.go b/packages/houdini-core/plugin/lists/paginationDocuments.go index 73c759a82..9d0eddc51 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments.go @@ -172,7 +172,11 @@ func PreparePaginationDocuments( AND tf.name = je.value LEFT JOIN documents existing_operations ON existing_operations.name = documents.name || $pagination_suffix WHERE (raw_documents.current_task = $task_id OR $task_id IS NULL) - AND documents.name NOT LIKE '%_paginated%' + -- exclude the internally-generated "_paginated" fragments. GLOB (not LIKE) + -- so the underscore is matched literally and case-sensitively; LIKE's '_' is a + -- single-char wildcard, which wrongly excluded any user document whose name merely + -- contained "paginated" (e.g. "MyPaginatedList"). + AND documents.name NOT GLOB '*_paginated*' AND existing_operations.id IS NULL AND (documents.processed = false OR documents.processed IS NULL) GROUP BY discovered_lists.id diff --git a/packages/houdini-core/plugin/lists/paginationDocuments_test.go b/packages/houdini-core/plugin/lists/paginationDocuments_test.go index a041a60cb..3bc659a03 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments_test.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments_test.go @@ -559,6 +559,166 @@ func TestPaginationDocumentGeneration(t *testing.T) { )), }, }, + { + // regression: a fragment whose name contains "paginated" must still get its + // pagination documents generated. discovery used `NOT LIKE '%_paginated%'` to + // skip the internal "_paginated" docs, but LIKE's '_' is a single-char + // wildcard, so "MyPaginatedFriends" was wrongly skipped (issue surfaced via #1408). + Name: "fragment whose name contains 'paginated'", + Pass: true, + Input: []string{ + ` + fragment MyPaginatedFriends on User { + friends(first: 10) @paginate { + edges { + node { + firstName + } + } + } + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` + fragment MyPaginatedFriends_paginated_c9Zhk on User { + __typename + friends(first: $first, after: $after, last: $last, before: $before) @paginate { + edges { + node { + firstName + __typename + id + } + cursor + __typename + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + __typename + } + id + } + `).WithVariables( + tests.ExpectedOperationVariable{ + Name: "first", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "after", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "last", + Type: "Int", + }, + tests.ExpectedOperationVariable{ + Name: "before", + Type: "String", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { + node(id: $id) { + ...MyPaginatedFriends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) + __typename + id + } + } + `, + graphql.FragmentPaginationQueryName("MyPaginatedFriends"), + )), + }, + }, + { + // companion to the case above: "paginated" at the very START of the name has no + // preceding character, so the buggy `%_paginated%` LIKE never matched it. testing + // it guards against an over-eager fix (e.g. `GLOB '*paginated*'` dropping the + // underscore) that would wrongly exclude it. + Name: "fragment whose name starts with 'paginated'", + Pass: true, + Input: []string{ + ` + fragment PaginatedFriends on User { + friends(first: 10) @paginate { + edges { + node { + firstName + } + } + } + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` + fragment PaginatedFriends_paginated_c9Zhk on User { + __typename + friends(first: $first, after: $after, last: $last, before: $before) @paginate { + edges { + node { + firstName + __typename + id + } + cursor + __typename + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + __typename + } + id + } + `).WithVariables( + tests.ExpectedOperationVariable{ + Name: "first", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "after", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "last", + Type: "Int", + }, + tests.ExpectedOperationVariable{ + Name: "before", + Type: "String", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { + node(id: $id) { + ...PaginatedFriends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) + __typename + id + } + } + `, + graphql.FragmentPaginationQueryName("PaginatedFriends"), + )), + }, + }, { Name: "fragment on custom resolve query", Pass: true, diff --git a/packages/houdini-react/package/vite/index.ts b/packages/houdini-react/package/vite/index.ts index c1f685c76..31d050b2a 100644 --- a/packages/houdini-react/package/vite/index.ts +++ b/packages/houdini-react/package/vite/index.ts @@ -64,10 +64,16 @@ const REACT_TSCONFIG_STUB = `{ // rolldown picks up even in SSR builds. const _require = createRequire(import.meta.url) let reactStreamingServerPath = '' +let reactStreamingHooksPath = '' try { const main = _require.resolve('react-streaming') const pkgDir = main.replace(/\/dist\/.*$/, '') reactStreamingServerPath = path.join(pkgDir, 'dist/server/index.node-and-web.js') + // The bare `react-streaming` entry resolves to the browser hooks (whose useStream is a + // no-op returning null) when rolldown picks the "browser" condition, which it does even in + // SSR builds. The runtime calls useStreamOptional() during SSR to grab injectToStream, so + // in the SSR build we must point the bare import at the server hooks instead. + reactStreamingHooksPath = path.join(pkgDir, 'dist/server/hooks.js') } catch {} export default function (ctx: VitePluginContext): PluginOption { @@ -104,7 +110,21 @@ export default function (ctx: VitePluginContext): PluginOption { // transform hook registered on this plugin instance. if (userConfig.build?.ssr) { return reactStreamingServerPath - ? { resolve: { alias: { 'react-streaming/server': reactStreamingServerPath } } } + ? { + resolve: { + alias: [ + { + find: 'react-streaming/server', + replacement: reactStreamingServerPath, + }, + // exact match so we don't also rewrite react-streaming/server + { + find: /^react-streaming$/, + replacement: reactStreamingHooksPath, + }, + ], + }, + } : {} } diff --git a/packages/houdini-react/plugin/generate.go b/packages/houdini-react/plugin/generate.go index e267e3928..aac63b9e5 100644 --- a/packages/houdini-react/plugin/generate.go +++ b/packages/houdini-react/plugin/generate.go @@ -592,6 +592,12 @@ export const on_render = { webStream: production, userAgent: 'Vite' } ) + // The page bootstrap below is intentionally not async. On a streaming page (e.g. an + // @loading query renders its loading state inside a Suspense boundary, so the shell + // flushes immediately and the document stays open), an async module runs the moment + // it loads — before the deferred react-refresh preamble, which waits for the still-open + // document to finish parsing. That makes every JSX import throw "can't detect preamble". + // A plain (deferred) module runs in document order, after the preamble. injectToStream(`+"`"+` ' : ''} + ${match ? '' : ''} `+"`"+`) if (pipeTo && pipe) { diff --git a/packages/houdini-react/runtime/hydration.tsx b/packages/houdini-react/runtime/hydration.tsx index c0f5e871a..1f66e754d 100644 --- a/packages/houdini-react/runtime/hydration.tsx +++ b/packages/houdini-react/runtime/hydration.tsx @@ -20,6 +20,7 @@ declare global { __houdini__pending_artifacts__?: Record __houdini__pending_data__?: Record __houdini__pending_variables__?: Record + __houdini__pending_cache__?: any[] __houdini__nav_caches__?: RouterCache } } @@ -57,6 +58,16 @@ export function hydrate_page( window.__houdini__hydration__layer__ ) + // apply any cache snapshots that streamed in before this module ran. an @loading query + // resolves while the document is still open, so its resolution script runs before this + // (deferred) module and couldn't hydrate the cache directly — it queued its snapshot here + // instead. drain the queue now that the cache exists so the observers below read the + // resolved data rather than the loading-state placeholder. + for (const snapshot of window.__houdini__pending_cache__ ?? []) { + window.__houdini__cache__?.hydrate(snapshot, window.__houdini__hydration__layer__) + } + window.__houdini__pending_cache__ = [] + // prime the data/artifact caches from anything the server streamed const initialData: Record = {} const initialArtifacts: Record = {} diff --git a/packages/houdini-react/runtime/routing/Router.tsx b/packages/houdini-react/runtime/routing/Router.tsx index 929bf0b19..3863a6f0b 100644 --- a/packages/houdini-react/runtime/routing/Router.tsx +++ b/packages/houdini-react/runtime/routing/Router.tsx @@ -11,6 +11,7 @@ import { find_match, find_prefix_match } from 'houdini/router/match' import type { RouterManifest, RouterPageManifest } from 'houdini/router/types' import React from 'react' import { useContext, useEffect } from 'react' +import { useStreamOptional } from 'react-streaming' import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js' import type { Goto } from '../routes.js' @@ -80,13 +81,22 @@ export function Router({ // is if we don't have the component source. Dependencies on query results or artifacts // will be resolved by the component itself + // On the server we stream the resolved data for @loading queries back to the client as + // the boundaries resolve (see load_query). The injectToStream handle for that comes from + // react-streaming's context rather than a prop: the server entry only receives it as a + // return value of renderToStream(), after has already been constructed, so it can't + // be threaded down as a prop in time for the first render. useStreamOptional() returns null + // on the client (and in non-streaming renders), leaving the prop as the fallback. + const stream = useStreamOptional() + const effectiveInjectToStream = injectToStream ?? stream?.injectToStream + // load the page assets (source, artifacts, data). this will suspend if the component is not available yet // this hook embeds pending requests in context so that the component can suspend if necessary14 const { loadData, loadComponent } = usePageData({ page: targetPage, variables, assetPrefix, - injectToStream, + injectToStream: effectiveInjectToStream, }) // if we get this far, it's safe to load the component const { component_cache, data_cache, ssr_signals } = useRouterContext() @@ -314,7 +324,18 @@ function usePageData({ injectToStream?.(` + +{#if $store.data} +
+ {#if $store.data.user.name === PendingValue} + loading... + {:else} + {$store.data.user.name} + {/if} +
+ + + +{/if} diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts new file mode 100644 index 000000000..c2f6fae16 --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts @@ -0,0 +1,21 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +// issue #1408: a @paginate fragment spread on a document-level @loading query. the fragment +// itself carries no @loading; it relies on the query-level @loading cascading into the spread. +const store = graphql(` + query PaginatedFragmentAtLoading @loading { + user(id: "1", snapshot: "paginated-fragment-at-loading", delay: 2000) { + name + ...PaginatedFragmentAtLoading_Friends + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + PaginatedFragmentAtLoading: store, + } +} diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte new file mode 100644 index 000000000..2087080e3 --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte @@ -0,0 +1,30 @@ + + +{#if $store.data} +
+ {$store.data.friendsConnection.edges.map(({ node }) => node?.name).join(', ')} +
+{/if} + + + diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts new file mode 100644 index 000000000..816ffd016 --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts @@ -0,0 +1,34 @@ +import { test } from '@playwright/test' +import { routes } from '../../../lib/utils/routes.js' +import { + clientSideNavigation, + expect_0_gql, + expect_1_gql, + expect_to_be, + goto, +} from '../../../lib/utils/testsHelper.js' + +test.describe('paginated fragment under a document-level @loading query', () => { + // issue #1408: a @paginate fragment spread on an @loading query. The child is rendered + // unguarded, so its pagination handlers can be invoked while the parent entity is still a + // PendingValue placeholder. They must no-op then (instead of firing node(id: PendingValue)) + // and resume working once @loading resolves — all without an `if (!loading)` guard. + test('no-ops while loading, renders and paginates after', async ({ page }) => { + // must navigate client-side to actually see the @loading frame + await goto(page, routes.Home) + await clientSideNavigation(page, routes.paginated_fragment_at_loading) + + // we're in the loading frame + await expect_to_be(page, 'loading...', '#name') + + // clicking next while the parent is still loading must not fire a request + await expect_0_gql(page, 'button[id=next]') + + // once @loading resolves the friends render + await expect_to_be(page, 'Bruce Willis, Samuel Jackson', '#result') + + // and pagination works from there + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks', '#result') + }) +}) diff --git a/e2e/react/src/routes/loading-paginated-fragment/+page.gql b/e2e/react/src/routes/loading-paginated-fragment/+page.gql index 1b550dbdf..a48efe06c 100644 --- a/e2e/react/src/routes/loading-paginated-fragment/+page.gql +++ b/e2e/react/src/routes/loading-paginated-fragment/+page.gql @@ -1,5 +1,5 @@ query LoadingFragmentQuery @loading { - user(id: "1", snapshot: "loading-paginated-fragment", delay: 400) { + user(id: "1", snapshot: "loading-paginated-fragment", delay: 2000) { name ...LoadingFragmentList } diff --git a/e2e/react/src/routes/loading-paginated-fragment/test.ts b/e2e/react/src/routes/loading-paginated-fragment/test.ts index ea290bde9..7a1a12bb1 100644 --- a/e2e/react/src/routes/loading-paginated-fragment/test.ts +++ b/e2e/react/src/routes/loading-paginated-fragment/test.ts @@ -1,6 +1,12 @@ import { test } from '@playwright/test' import { routes } from '~/utils/routes.js' -import { expect_to_be, expect_1_gql, goto } from '~/utils/testsHelper.js' +import { + clientSideNavigation, + expect_0_gql, + expect_1_gql, + expect_to_be, + goto, +} from '~/utils/testsHelper.js' test.describe('paginated fragment under an @loading query', () => { // issue #1408: a paginated fragment spread on an @loading query mounts during the @@ -16,4 +22,27 @@ test.describe('paginated fragment under an @loading query', () => { await expect_1_gql(page, 'button[id=next]') await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') }) + + // the pagination handlers must no-op while the parent entity is still a PendingValue + // placeholder, instead of firing a node(id: PendingValue) request. the loading frame is + // only observable via a client-side navigation (a direct load streams until resolved). + test('paginating during the loading frame is a no-op', async ({ page }) => { + await goto(page, '/') + await clientSideNavigation(page, routes.loading_paginated_fragment) + + // we're in the loading frame + await expect_to_be(page, 'loading', '#name') + + // clicking next while the parent is still loading must not fire a request + await expect_0_gql(page, 'button[id=next]') + + // once @loading resolves the friends render (the #result node is present but empty + // during loading, so wait for the data before asserting) and pagination works + await page.waitForFunction(() => + document.querySelector('#result')?.textContent?.includes('Bruce') + ) + await expect_to_be(page, 'Bruce Willis, Samuel Jackson', '#result') + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks', '#result') + }) }) diff --git a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts index bbb7685f7..2d3bd425b 100644 --- a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts +++ b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts @@ -31,7 +31,10 @@ export function useFragmentHandle< document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact } ): any { const fragmentData = useFragment<_Data, _ReferenceType, _Input>(reference, document) - const { variables } = fragmentReference<_Data, _Input, _ReferenceType>(reference, document) + const { variables, loading } = fragmentReference<_Data, _Input, _ReferenceType>( + reference, + document + ) const client = useClient() const [session] = useSession() @@ -172,6 +175,8 @@ export function useFragmentHandle< getState: () => displayData as _Data | null, getVariables: () => (paginationObserver.state.variables ?? variables) as NonNullable<_Input>, + // no-op pagination while the parent is still in its @loading state (issue #1408) + getLoading: () => loading, fetch: fetchFn, fetchUpdate, getSession: async () => session, @@ -196,6 +201,8 @@ export function useFragmentHandle< getState: () => displayData as _Data | null, getVariables: () => (paginationObserver.state.variables ?? variables) as NonNullable<_Input>, + // no-op pagination while the parent is still in its @loading state (issue #1408) + getLoading: () => loading, storeName: refetchArtifact.name, fetch: fetchFn, fetchUpdate: async (args: any, updates = ['append']) => @@ -210,7 +217,15 @@ export function useFragmentHandle< } return null - }, [refetchArtifact, paginationObserver, displayData, session, forwardPending, backwardPending]) + }, [ + refetchArtifact, + paginationObserver, + displayData, + session, + forwardPending, + backwardPending, + loading, + ]) // the fragment's current argument values: the initial args overlaid with everything that // has been passed to refetch() so far. we track these explicitly rather than reading them diff --git a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts index c4b25955b..b19d51a45 100644 --- a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts +++ b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts @@ -102,6 +102,11 @@ export class FragmentStoreCursor< }) const store = base.get(initialValue) + // the cache stamps a `loading` flag onto the fragment reference while its parent is + // rendering an @loading state. the pagination handlers key off this to no-op rather than + // firing a node(id: PendingValue) request that can't resolve (issue #1408). + const getLoading = () => Boolean((initialValue as any)?.[fragmentKey]?.loading) + const isSinglePage = this.paginationArtifact.refetch?.mode === 'SinglePage' let paginationStore: DocumentStore<_Data, _Input> @@ -169,7 +174,8 @@ export class FragmentStoreCursor< return store.variables as NonNullable<_Input> }, previousCursors, - nextCursors + nextCursors, + getLoading ) const subscribe = ( @@ -213,11 +219,13 @@ export class FragmentStoreCursor< getState: () => _Data | null, getVariables: () => NonNullable<_Input>, previousCursors?: (string | null)[], - nextCursors?: (string | null)[] + nextCursors?: (string | null)[], + getLoading?: () => boolean ): CursorHandlers<_Data, _Input> { return cursorHandlers<_Data, _Input>({ getState, getVariables, + getLoading, artifact: this.paginationArtifact, fetchUpdate: async (args, updates) => { await initClient() @@ -294,10 +302,14 @@ export class FragmentStoreOffset< const getState = () => get(store) + // no-op pagination while the parent is rendering an @loading state (issue #1408) + const getLoading = () => Boolean((initialValue as any)?.[fragmentKey]?.loading) + // create the offset handlers we'll add to the store const handlers = offsetHandlers<_Data, _Input>({ getState, getVariables: () => store.variables as _Input, + getLoading, artifact: this.paginationArtifact, fetch: async (args) => { return paginationStore.send({ diff --git a/packages/houdini/src/runtime/pagination.ts b/packages/houdini/src/runtime/pagination.ts index ab0652597..22acd6d91 100644 --- a/packages/houdini/src/runtime/pagination.ts +++ b/packages/houdini/src/runtime/pagination.ts @@ -23,6 +23,7 @@ export function cursorHandlers< getState, getVariables, getSession, + getLoading, previousCursors = [], nextCursors = [], }: { @@ -32,6 +33,10 @@ export function cursorHandlers< getSession: () => Promise fetch: FetchFn<_Data, _Input> fetchUpdate: (arg: SendParams, updates: string[]) => ReturnType> + // true while the fragment is spread on a parent that is still rendering its @loading state. + // the cache stamps this onto the fragment reference, so the handlers can no-op instead of + // firing a node(id: PendingValue) request that can never resolve (issue #1408). + getLoading?: () => boolean previousCursors?: (string | null)[] nextCursors?: (string | null)[] }): CursorHandlers<_Data, _Input> { @@ -107,6 +112,19 @@ export function cursorHandlers< return extractPageInfo(getState(), artifact.refetch?.path ?? []) } + // a resolved no-op result used when there is nothing to paginate (e.g. the parent entity + // is still in its @loading state, so its id is a PendingValue placeholder) + const loadingNoOp = (): Promise> => + Promise.resolve({ + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables(), + }) + return { loadNextPage: ({ first, @@ -119,6 +137,12 @@ export function cursorHandlers< fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { + // the parent entity hasn't resolved yet (still @loading): don't fire a node(id: + // PendingValue) request that can't succeed + if (getLoading?.()) { + return loadingNoOp() + } + const isSinglePage = artifact.refetch?.mode === 'SinglePage' const direction = artifact.refetch?.direction @@ -220,6 +244,11 @@ export function cursorHandlers< fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { + // the parent entity hasn't resolved yet (still @loading): nothing to paginate + if (getLoading?.()) { + return loadingNoOp() + } + const isSinglePage = artifact.refetch?.mode === 'SinglePage' const direction = artifact.refetch?.direction @@ -307,6 +336,11 @@ export function cursorHandlers< async fetch(args?: FetchParams<_Input>): Promise> { const { variables } = args ?? {} + // the parent entity hasn't resolved yet (still @loading): nothing to refetch + if (getLoading?.()) { + return loadingNoOp() + } + // if the input is different than the query variables then we just do everything like normal if (variables && !deepEquals(getVariables(), variables)) { return await parentFetch(args) @@ -390,6 +424,7 @@ export function offsetHandlers< fetch: parentFetch, fetchUpdate: parentFetchUpdate, getSession, + getLoading, }: { artifact: QueryArtifact fetch: FetchFn<_Data, _Input> @@ -398,6 +433,8 @@ export function offsetHandlers< getState: () => _Data | null getVariables: () => _Input getSession: () => Promise + // true while the fragment's parent is still rendering its @loading state (issue #1408) + getLoading?: () => boolean }) { // Get the Pagination Mode const isSinglePage = artifact.refetch?.mode === 'SinglePage' @@ -431,6 +468,11 @@ export function offsetHandlers< fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { + // the parent entity hasn't resolved yet (still @loading): nothing to paginate + if (getLoading?.()) { + return + } + // build up the variables to pass to the query const queryVariables: Record = { ...getVariables(), @@ -463,6 +505,19 @@ export function offsetHandlers< async fetch(params: FetchParams<_Input> = {}): Promise> { const { variables } = params + // the parent entity hasn't resolved yet (still @loading): nothing to refetch + if (getLoading?.()) { + return { + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables() ?? null, + } + } + // if the input is different than the query variables then we just do everything like normal if (variables && !deepEquals(getVariables(), variables)) { return parentFetch.call(this, params) From 8bb60012c5324e89d38041d010272c23df1341e1 Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Mon, 22 Jun 2026 00:26:42 -0700 Subject: [PATCH 3/4] Merge hydration snapshot layers instead of clobbering; reformat loading goldens --- .../artifacts/selection_loading_test.go | 574 +++++++++++++++++- packages/houdini-react/runtime/hydration.tsx | 10 +- .../houdini-react/runtime/routing/Router.tsx | 7 +- packages/houdini/src/runtime/cache/storage.ts | 4 + 4 files changed, 580 insertions(+), 15 deletions(-) diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go index 5af398dd2..2a70a8314 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go @@ -1146,7 +1146,181 @@ export type Query$artifact = typeof artifact }`, }, Extra: map[string]any{ - "Query": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"Query\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a\",\n \"raw\": `query Query {\n entities {\n ... on User {\n firstName\n __typename\n id\n }\n ... on Cat {\n name\n __typename\n id\n }\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"entities\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"entities\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"Cat\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"name\": {\n \"type\": \"String\",\n \"keyRaw\": \"name\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"typeMap\": {},\n },\n\n \"loadingTypes\": [\"Cat\", \"User\"],\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n \"list\": {\n \"depth\": 1,\n \"count\": 3,\n },\n },\n \"abstract\": true,\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"global\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type Query = {\n\treadonly \"input\"?: Query$input;\n\treadonly \"result\": Query$result | undefined;\n};\n\nexport type Query$result = {\n\treadonly entities: ({} & (({\n\t\treadonly name: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n} | {\n\treadonly entities: ({} & (({\n\t\treadonly name: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$input = null | undefined;\n\nexport type Query$unmasked = {\n\treadonly entities: ({} & (({\n\t\treadonly id: string;\n\t\treadonly name: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$artifact = typeof artifact\n\n\"HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a\"", + "Query": tests.Dedent( + `import type { LoadingType } from "houdini/runtime"; +const artifact = { + "name": "Query", + "kind": "HoudiniQuery", + "hash": "75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a", + "raw": ` + "`" + `query Query { + entities { + ... on User { + firstName + __typename + id + } + ... on Cat { + name + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "entities": { + "type": "Entity", + "keyRaw": "entities", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + }, + "abstractFields": { + "fields": { + "Cat": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + "id": { + "type": "ID", + "keyRaw": "id", + "loading": { + "kind": "value", + }, + }, + "name": { + "type": "String", + "keyRaw": "name", + "loading": { + "kind": "value", + }, + "visible": true, + }, + }, + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + "firstName": { + "type": "String", + "keyRaw": "firstName", + "loading": { + "kind": "value", + }, + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + "loading": { + "kind": "value", + }, + }, + }, + }, + + "typeMap": {}, + }, + + "loadingTypes": ["Cat", "User"], + }, + + "loading": { + "kind": "continue", + "list": { + "depth": 1, + "count": 3, + }, + }, + "abstract": true, + "visible": true, + }, + }, + }, + + "pluginData": {}, + "enableLoadingState": "global", + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type Query = { + readonly "input"?: Query$input; + readonly "result": Query$result | undefined; +}; + +export type Query$result = { + readonly entities: ({} & (({ + readonly name: string; + readonly id: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +} | { + readonly entities: ({} & (({ + readonly name: LoadingType; + readonly id: LoadingType; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: LoadingType; + readonly id: LoadingType; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + +export type Query$input = null | undefined; + +export type Query$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + +export type Query$artifact = typeof artifact + +"HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a"`, + ), }, }, { @@ -1164,16 +1338,263 @@ export type Query$artifact = typeof artifact } } - b: entities { - ... on User { - firstName - } - } - } - `, - }, - Extra: map[string]any{ - "Query": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"Query\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877\",\n \"raw\": `query Query {\n entities {\n ... on User {\n firstName\n __typename\n id\n }\n ... on Cat {\n name\n __typename\n id\n }\n __typename\n }\n b: entities {\n ... on User {\n firstName\n __typename\n id\n }\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"b\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"b\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n },\n },\n },\n\n \"typeMap\": {},\n },\n },\n\n \"abstract\": true,\n \"visible\": true,\n },\n\n \"entities\": {\n \"type\": \"Entity\",\n \"keyRaw\": \"entities\",\n\n \"directives\": [{\n \"name\": \"loading\",\n \"arguments\": {\n \"cascade\": {\n \"kind\": \"BooleanValue\",\n \"value\": true\n }\n }\n }],\n\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n \"abstractFields\": {\n \"fields\": {\n \"Cat\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"name\": {\n \"type\": \"String\",\n \"keyRaw\": \"name\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n \"User\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n \"firstName\": {\n \"type\": \"String\",\n \"keyRaw\": \"firstName\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"typeMap\": {},\n },\n\n \"loadingTypes\": [\"Cat\", \"User\"],\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n \"list\": {\n \"depth\": 1,\n \"count\": 3,\n },\n },\n \"abstract\": true,\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"local\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type Query = {\n\treadonly \"input\"?: Query$input;\n\treadonly \"result\": Query$result | undefined;\n};\n\nexport type Query$result = {\n\treadonly entities: ({} & (({\n\t\treadonly name: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n\treadonly b: ({} & (({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n} | {\n\treadonly entities: ({} & (({\n\t\treadonly name: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: LoadingType;\n\t\treadonly id: LoadingType;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$input = null | undefined;\n\nexport type Query$unmasked = {\n\treadonly b: ({} & (({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n\treadonly entities: ({} & (({\n\t\treadonly id: string;\n\t\treadonly name: string;\n\t\treadonly __typename: \"Cat\";\n\t}) | ({\n\t\treadonly firstName: string;\n\t\treadonly id: string;\n\t\treadonly __typename: \"User\";\n\t}) | ({\n\t\treadonly \" $fragments\"?: {};\n\t\treadonly __typename: \"non-exhaustive; don't match this\";\n\t})))[];\n};\n\nexport type Query$artifact = typeof artifact\n\n\"HoudiniHash=8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877\"", + b: entities { + ... on User { + firstName + } + } + } + `, + }, + Extra: map[string]any{ + "Query": tests.Dedent( + `import type { LoadingType } from "houdini/runtime"; +const artifact = { + "name": "Query", + "kind": "HoudiniQuery", + "hash": "8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877", + "raw": ` + "`" + `query Query { + entities { + ... on User { + firstName + __typename + id + } + ... on Cat { + name + __typename + id + } + __typename + } + b: entities { + ... on User { + firstName + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "b": { + "type": "Entity", + "keyRaw": "b", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + }, + "abstractFields": { + "fields": { + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "typeMap": {}, + }, + }, + + "abstract": true, + "visible": true, + }, + + "entities": { + "type": "Entity", + "keyRaw": "entities", + + "directives": [{ + "name": "loading", + "arguments": { + "cascade": { + "kind": "BooleanValue", + "value": true + } + } + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + }, + "abstractFields": { + "fields": { + "Cat": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + "id": { + "type": "ID", + "keyRaw": "id", + "loading": { + "kind": "value", + }, + }, + "name": { + "type": "String", + "keyRaw": "name", + "loading": { + "kind": "value", + }, + "visible": true, + }, + }, + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + "firstName": { + "type": "String", + "keyRaw": "firstName", + "loading": { + "kind": "value", + }, + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + "loading": { + "kind": "value", + }, + }, + }, + }, + + "typeMap": {}, + }, + + "loadingTypes": ["Cat", "User"], + }, + + "loading": { + "kind": "continue", + "list": { + "depth": 1, + "count": 3, + }, + }, + "abstract": true, + "visible": true, + }, + }, + }, + + "pluginData": {}, + "enableLoadingState": "local", + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type Query = { + readonly "input"?: Query$input; + readonly "result": Query$result | undefined; +}; + +export type Query$result = { + readonly entities: ({} & (({ + readonly name: string; + readonly id: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + readonly b: ({} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +} | { + readonly entities: ({} & (({ + readonly name: LoadingType; + readonly id: LoadingType; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: LoadingType; + readonly id: LoadingType; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + +export type Query$input = null | undefined; + +export type Query$unmasked = { + readonly b: ({} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + +export type Query$artifact = typeof artifact + +"HoudiniHash=8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877"`, + ), }, }, { @@ -1200,7 +1621,136 @@ export type Query$artifact = typeof artifact `, }, Extra: map[string]any{ - "GlobalLoadingSpreadQuery": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"GlobalLoadingSpreadQuery\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370\",\n \"raw\": `fragment ConnectionInfo on AnimalConnection {\n pageInfo {\n hasNextPage\n __typename\n }\n __typename\n}\n\nquery GlobalLoadingSpreadQuery {\n monkeys {\n ...ConnectionInfo\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"monkeys\": {\n \"type\": \"MonkeyConnection\",\n \"keyRaw\": \"monkeys\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"pageInfo\": {\n \"type\": \"PageInfo\",\n \"keyRaw\": \"pageInfo\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"hasNextPage\": {\n \"type\": \"Boolean\",\n \"keyRaw\": \"hasNextPage\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n },\n },\n\n \"fragments\": {\n \"ConnectionInfo\": {\n \"arguments\": {},\n \"loading\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"global\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type GlobalLoadingSpreadQuery = {\n\treadonly \"input\"?: GlobalLoadingSpreadQuery$input;\n\treadonly \"result\": GlobalLoadingSpreadQuery$result | undefined;\n};\n\nexport type GlobalLoadingSpreadQuery$result = {\n\treadonly monkeys: {\n\t\treadonly \" $fragments\": {\n\t\t\tConnectionInfo: {};\n\t\t};\n\t};\n} | {\n\treadonly monkeys: {\n\t\treadonly \" $fragments\": {\n\t\t\tConnectionInfo: {};\n\t\t};\n\t};\n};\n\nexport type GlobalLoadingSpreadQuery$input = null | undefined;\n\nexport type GlobalLoadingSpreadQuery$unmasked = {\n\treadonly monkeys: {\n\t\treadonly __typename: \"MonkeyConnection\";\n\t\treadonly pageInfo: {\n\t\t\treadonly __typename: \"PageInfo\";\n\t\t\treadonly hasNextPage: boolean;\n\t\t};\n\t};\n};\n\nexport type GlobalLoadingSpreadQuery$artifact = typeof artifact\n\n\"HoudiniHash=f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370\"", + "GlobalLoadingSpreadQuery": tests.Dedent( + `import type { LoadingType } from "houdini/runtime"; +const artifact = { + "name": "GlobalLoadingSpreadQuery", + "kind": "HoudiniQuery", + "hash": "f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370", + "raw": ` + "`" + `fragment ConnectionInfo on AnimalConnection { + pageInfo { + hasNextPage + __typename + } + __typename +} + +query GlobalLoadingSpreadQuery { + monkeys { + ...ConnectionInfo + __typename + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "monkeys": { + "type": "MonkeyConnection", + "keyRaw": "monkeys", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + + "pageInfo": { + "type": "PageInfo", + "keyRaw": "pageInfo", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + + "hasNextPage": { + "type": "Boolean", + "keyRaw": "hasNextPage", + "loading": { + "kind": "value", + }, + }, + }, + }, + + "loading": { + "kind": "continue", + }, + }, + }, + + "fragments": { + "ConnectionInfo": { + "arguments": {}, + "loading": true, + }, + }, + }, + + "loading": { + "kind": "continue", + }, + "visible": true, + }, + }, + }, + + "pluginData": {}, + "enableLoadingState": "global", + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type GlobalLoadingSpreadQuery = { + readonly "input"?: GlobalLoadingSpreadQuery$input; + readonly "result": GlobalLoadingSpreadQuery$result | undefined; +}; + +export type GlobalLoadingSpreadQuery$result = { + readonly monkeys: { + readonly " $fragments": { + ConnectionInfo: {}; + }; + }; +} | { + readonly monkeys: { + readonly " $fragments": { + ConnectionInfo: {}; + }; + }; +}; + +export type GlobalLoadingSpreadQuery$input = null | undefined; + +export type GlobalLoadingSpreadQuery$unmasked = { + readonly monkeys: { + readonly __typename: "MonkeyConnection"; + readonly pageInfo: { + readonly __typename: "PageInfo"; + readonly hasNextPage: boolean; + }; + }; +}; + +export type GlobalLoadingSpreadQuery$artifact = typeof artifact + +"HoudiniHash=f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370"`, + ), }, }, }, diff --git a/packages/houdini-react/runtime/hydration.tsx b/packages/houdini-react/runtime/hydration.tsx index 1f66e754d..f76d67a39 100644 --- a/packages/houdini-react/runtime/hydration.tsx +++ b/packages/houdini-react/runtime/hydration.tsx @@ -62,9 +62,15 @@ export function hydrate_page( // resolves while the document is still open, so its resolution script runs before this // (deferred) module and couldn't hydrate the cache directly — it queued its snapshot here // instead. drain the queue now that the cache exists so the observers below read the - // resolved data rather than the loading-state placeholder. + // resolved data rather than the loading-state placeholder. each snapshot goes into its own + // layer (hydrate() replaces a layer's contents wholesale, so reusing one would keep only + // the last snapshot) and is then merged down so we end up with a single hydration layer. + const storage = window.__houdini__cache__?._internal_unstable.storage for (const snapshot of window.__houdini__pending_cache__ ?? []) { - window.__houdini__cache__?.hydrate(snapshot, window.__houdini__hydration__layer__) + const layer = window.__houdini__cache__?.hydrate(snapshot) + if (layer && storage) { + storage.resolveLayer(layer.id) + } } window.__houdini__pending_cache__ = [] diff --git a/packages/houdini-react/runtime/routing/Router.tsx b/packages/houdini-react/runtime/routing/Router.tsx index 3863a6f0b..16872a279 100644 --- a/packages/houdini-react/runtime/routing/Router.tsx +++ b/packages/houdini-react/runtime/routing/Router.tsx @@ -332,7 +332,12 @@ function usePageData({ // would be dropped and the query would hydrate with null data. const __houdini__snapshot__ = ${cache.serialize()} if (window.__houdini__cache__) { - window.__houdini__cache__.hydrate(__houdini__snapshot__, window.__houdini__hydration__layer__) + // hydrate into a fresh layer and merge it down, rather than clobbering the + // shared hydration layer (which would drop everything hydrated before it) + const __houdini__layer__ = window.__houdini__cache__.hydrate(__houdini__snapshot__) + if (__houdini__layer__) { + window.__houdini__cache__._internal_unstable.storage.resolveLayer(__houdini__layer__.id) + } } else { (window.__houdini__pending_cache__ = window.__houdini__pending_cache__ || []).push(__houdini__snapshot__) } diff --git a/packages/houdini/src/runtime/cache/storage.ts b/packages/houdini/src/runtime/cache/storage.ts index a8ea53bf9..a98777c5e 100644 --- a/packages/houdini/src/runtime/cache/storage.ts +++ b/packages/houdini/src/runtime/cache/storage.ts @@ -349,6 +349,10 @@ export class InMemoryStorage { layer ??= this.createLayer(true) layer.fields = fields layer.links = links + + // return the layer so callers that hydrate several snapshots can merge them back down + // (via resolveLayer) instead of reusing — and clobbering — a single shared layer + return layer } // fast typename lookup with lazy-populate fallback for records written via hydrate() From 3be6ba784771d86f82c3b3a36daf67f5a49cb74a Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Mon, 22 Jun 2026 00:58:36 -0700 Subject: [PATCH 4/4] Source injectToStream via a holder prop instead of importing react-streaming --- packages/houdini-react/package/vite/index.ts | 22 +------------------ packages/houdini-react/plugin/generate.go | 12 ++++++++++ .../houdini-react/runtime/routing/Router.tsx | 12 +--------- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/houdini-react/package/vite/index.ts b/packages/houdini-react/package/vite/index.ts index 31d050b2a..c1f685c76 100644 --- a/packages/houdini-react/package/vite/index.ts +++ b/packages/houdini-react/package/vite/index.ts @@ -64,16 +64,10 @@ const REACT_TSCONFIG_STUB = `{ // rolldown picks up even in SSR builds. const _require = createRequire(import.meta.url) let reactStreamingServerPath = '' -let reactStreamingHooksPath = '' try { const main = _require.resolve('react-streaming') const pkgDir = main.replace(/\/dist\/.*$/, '') reactStreamingServerPath = path.join(pkgDir, 'dist/server/index.node-and-web.js') - // The bare `react-streaming` entry resolves to the browser hooks (whose useStream is a - // no-op returning null) when rolldown picks the "browser" condition, which it does even in - // SSR builds. The runtime calls useStreamOptional() during SSR to grab injectToStream, so - // in the SSR build we must point the bare import at the server hooks instead. - reactStreamingHooksPath = path.join(pkgDir, 'dist/server/hooks.js') } catch {} export default function (ctx: VitePluginContext): PluginOption { @@ -110,21 +104,7 @@ export default function (ctx: VitePluginContext): PluginOption { // transform hook registered on this plugin instance. if (userConfig.build?.ssr) { return reactStreamingServerPath - ? { - resolve: { - alias: [ - { - find: 'react-streaming/server', - replacement: reactStreamingServerPath, - }, - // exact match so we don't also rewrite react-streaming/server - { - find: /^react-streaming$/, - replacement: reactStreamingHooksPath, - }, - ], - }, - } + ? { resolve: { alias: { 'react-streaming/server': reactStreamingServerPath } } } : {} } diff --git a/packages/houdini-react/plugin/generate.go b/packages/houdini-react/plugin/generate.go index aac63b9e5..ae585a68c 100644 --- a/packages/houdini-react/plugin/generate.go +++ b/packages/houdini-react/plugin/generate.go @@ -573,6 +573,16 @@ export const on_render = // HoudiniErrorBoundary can set the correct HTTP status/location before streaming. const statusRef = { status: is404 ? 404 : 200, location: undefined } + // renderToStream only hands back injectToStream as a return value, i.e. after has + // already been constructed — too late to pass it down as a prop for the first render. + // We thread a stable wrapper that delegates to this holder, then fill the holder once + // renderToStream resolves. @loading queries resolve after the shell flushes (so the + // holder is set by the time their resolution scripts stream); non-@loading queries + // resolve before the shell and simply no-op the wrapper, falling back to the initial + // cache as before. Sourcing it this way (rather than react-streaming's useStream context) + // keeps the bare react-streaming import out of the isomorphic runtime, which trips a + // "loaded in browser" poison-pill assertion under browser-like test environments. + const streamHolder = {} const { readable, injectToStream, @@ -586,11 +596,13 @@ export const on_render = assetPrefix: assetPrefix, manifest: manifest, cssLinks: cssLinks || [], + injectToStream: (chunk) => streamHolder.injectToStream?.(chunk), ...router_cache() }) ), { webStream: production, userAgent: 'Vite' } ) + streamHolder.injectToStream = injectToStream // The page bootstrap below is intentionally not async. On a streaming page (e.g. an // @loading query renders its loading state inside a Suspense boundary, so the shell diff --git a/packages/houdini-react/runtime/routing/Router.tsx b/packages/houdini-react/runtime/routing/Router.tsx index 16872a279..5245fa059 100644 --- a/packages/houdini-react/runtime/routing/Router.tsx +++ b/packages/houdini-react/runtime/routing/Router.tsx @@ -11,7 +11,6 @@ import { find_match, find_prefix_match } from 'houdini/router/match' import type { RouterManifest, RouterPageManifest } from 'houdini/router/types' import React from 'react' import { useContext, useEffect } from 'react' -import { useStreamOptional } from 'react-streaming' import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js' import type { Goto } from '../routes.js' @@ -81,22 +80,13 @@ export function Router({ // is if we don't have the component source. Dependencies on query results or artifacts // will be resolved by the component itself - // On the server we stream the resolved data for @loading queries back to the client as - // the boundaries resolve (see load_query). The injectToStream handle for that comes from - // react-streaming's context rather than a prop: the server entry only receives it as a - // return value of renderToStream(), after has already been constructed, so it can't - // be threaded down as a prop in time for the first render. useStreamOptional() returns null - // on the client (and in non-streaming renders), leaving the prop as the fallback. - const stream = useStreamOptional() - const effectiveInjectToStream = injectToStream ?? stream?.injectToStream - // load the page assets (source, artifacts, data). this will suspend if the component is not available yet // this hook embeds pending requests in context so that the component can suspend if necessary14 const { loadData, loadComponent } = usePageData({ page: targetPage, variables, assetPrefix, - injectToStream: effectiveInjectToStream, + injectToStream, }) // if we get this far, it's safe to load the component const { component_cache, data_cache, ssr_signals } = useRouterContext()