diff --git a/.changeset/every-ducks-fry.md b/.changeset/every-ducks-fry.md new file mode 100644 index 000000000..e252fba77 --- /dev/null +++ b/.changeset/every-ducks-fry.md @@ -0,0 +1,7 @@ +--- +"houdini-svelte": patch +"houdini-core": patch +"houdini": patch +--- + +Refresh active SvelteKit queries after `setSession` updates the Houdini session, ensuring refetched operations run with the latest session. diff --git a/e2e/kit/src/hooks.server.ts b/e2e/kit/src/hooks.server.ts index daac935d7..600ec4f97 100644 --- a/e2e/kit/src/hooks.server.ts +++ b/e2e/kit/src/hooks.server.ts @@ -4,7 +4,9 @@ import { sequence } from '@sveltejs/kit/hooks' const handleTests: Handle = async ({ event, resolve }) => { // set the session information for this event - setSession(event, { user: { token: '1234-Houdini-Token-5678' } }) + setSession(event, { + user: { token: event.cookies.get('houdini-session-token') ?? '1234-Houdini-Token-5678' }, + }) // pass the event onto the default handle return await resolve(event) diff --git a/e2e/kit/src/lib/utils/routes.ts b/e2e/kit/src/lib/utils/routes.ts index 4d9bb3bb0..06d63b320 100644 --- a/e2e/kit/src/lib/utils/routes.ts +++ b/e2e/kit/src/lib/utils/routes.ts @@ -41,6 +41,7 @@ export const routes = { Stores_action_mutation: '/stores/action-mutation', Stores_Endpoint_Query: '/stores/endpoint-query', Stores_Session: '/stores/session', + Stores_Session_Refresh: '/stores/session-refresh', Stores_Comp_disable_auto_fetch: '/stores/comp_disable_auto_fetch', Stores_Partial_List: '/stores/partial/partial_List', diff --git a/e2e/kit/src/routes/stores/session-refresh/+page.svelte b/e2e/kit/src/routes/stores/session-refresh/+page.svelte new file mode 100644 index 000000000..97ae4bd40 --- /dev/null +++ b/e2e/kit/src/routes/stores/session-refresh/+page.svelte @@ -0,0 +1,30 @@ + + +

Session Refresh

+ +
+ {$session.data?.session} +
+ + diff --git a/e2e/kit/src/routes/stores/session-refresh/spec.ts b/e2e/kit/src/routes/stores/session-refresh/spec.ts new file mode 100644 index 000000000..38c09ca7a --- /dev/null +++ b/e2e/kit/src/routes/stores/session-refresh/spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Request } from '@playwright/test' + +import { routes } from '../../../lib/utils/routes.js' + +const initialToken = '1234-Houdini-Token-5678' +const updatedToken = 'updated-Houdini-Token-0000' + +function isActiveSessionQuery(request: Request) { + return ( + request.url().endsWith(routes.GraphQL) && + (request.postData()?.includes('query ActiveSessionAfterInvalidate') ?? false) + ) +} + +test.describe('SvelteKit session refresh', () => { + test('refreshes active queries with the new Houdini session', async ({ page }) => { + const initialRequestPromise = page.waitForRequest(isActiveSessionQuery) + await page.goto(routes.Stores_Session_Refresh) + + const initialRequest = await initialRequestPromise + expect(initialRequest.headers().authorization).toBe(`Bearer ${initialToken}`) + await expect(page.locator('#result')).toHaveText(initialToken) + + const [refetchRequest] = await Promise.all([ + page.waitForRequest(isActiveSessionQuery), + page.waitForResponse((response) => response.url().endsWith('session-refresh/update')), + page.getByRole('button', { name: 'Update session' }).click(), + ]) + + expect(refetchRequest.headers().authorization).toBe(`Bearer ${updatedToken}`) + await expect(page.locator('#result')).toHaveText(updatedToken) + }) +}) diff --git a/e2e/kit/src/routes/stores/session-refresh/update/+server.ts b/e2e/kit/src/routes/stores/session-refresh/update/+server.ts new file mode 100644 index 000000000..4af15042d --- /dev/null +++ b/e2e/kit/src/routes/stores/session-refresh/update/+server.ts @@ -0,0 +1,15 @@ +import { setSession } from '$houdini' +import { json, type RequestHandler } from '@sveltejs/kit' + +const updatedToken = 'updated-Houdini-Token-0000' + +export const POST: RequestHandler = (event) => { + event.cookies.set('houdini-session-token', updatedToken, { + path: '/', + httpOnly: true, + sameSite: 'lax', + }) + setSession(event, { user: { token: updatedToken } }) + + return json({ token: updatedToken }) +} diff --git a/packages/houdini-core/runtime/plugins/query.ts b/packages/houdini-core/runtime/plugins/query.ts index a9c6eb15d..507c12d57 100644 --- a/packages/houdini-core/runtime/plugins/query.ts +++ b/packages/houdini-core/runtime/plugins/query.ts @@ -79,7 +79,7 @@ export const query = (cache: Cache) => if (message.kind === 'refetch') { ctx.documentStore.send({ policy: CachePolicy.NetworkOnly, - session: lastSession, + session: 'session' in message ? message.session : lastSession, metadata: ctx.metadata, }) return diff --git a/packages/houdini-core/runtime/public/tests/query.test.ts b/packages/houdini-core/runtime/public/tests/query.test.ts new file mode 100644 index 000000000..419747dee --- /dev/null +++ b/packages/houdini-core/runtime/public/tests/query.test.ts @@ -0,0 +1,85 @@ +import { Cache } from '../../../../houdini/src/runtime/cache/index.js' +import { createPluginHooks, DocumentStore, HoudiniClient } from 'houdini/runtime/client' +import { ArtifactKind, DataSource } from 'houdini/runtime/types' +import { beforeEach, expect, test, vi } from 'vitest' + +import { testConfigFile } from '../../../../houdini/src/test/index.js' +import { setMockConfig } from '../../config.js' +import { query } from '../../plugins/query.js' + +const config = testConfigFile() +beforeEach(async () => { + setMockConfig(config) +}) + +test('refreshAll with a new session refetches active queries with that session', async () => { + const cache = new Cache() + + const selection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + } + + cache.write({ + selection, + data: { viewer: { id: '1', firstName: 'bob' } }, + }) + + const fetchSpy = vi.fn() + const fakeFetch = () => ({ + network(ctx, { resolve }) { + fetchSpy(ctx) + resolve(ctx, { + data: { viewer: { id: '1', firstName: 'bob', __typename: 'User' } }, + errors: null, + fetching: false, + variables: null, + source: DataSource.Network, + partial: false, + stale: false, + }) + }, + }) + + const artifact = { + kind: ArtifactKind.Query, + hash: '7777', + raw: 'RAW_TEXT', + name: 'TestArtifact', + rootType: 'Query', + pluginData: {}, + stripVariables: [], + selection, + } + const client = new HoudiniClient({ + config: () => config, + plugins: [query(cache), fakeFetch], + }) + const store = new DocumentStore({ + client, + plugins: createPluginHooks([query(cache), fakeFetch]), + artifact, + config, + }) + + await store.send({ session: { token: 'old' }, variables: {} }) + fetchSpy.mockClear() + + cache.refreshAll({ token: 'new' }) + + await new Promise((r) => setTimeout(r, 0)) + + expect(fetchSpy).toHaveBeenCalledOnce() + expect(fetchSpy.mock.calls[0][0].session).toEqual({ token: 'new' }) +}) diff --git a/packages/houdini-svelte/package/vite/transform/init.test.ts b/packages/houdini-svelte/package/vite/transform/init.test.ts index 3ab975112..038707d5f 100644 --- a/packages/houdini-svelte/package/vite/transform/init.test.ts +++ b/packages/houdini-svelte/package/vite/transform/init.test.ts @@ -14,15 +14,25 @@ test('modifies root +layout.svelte to import adapter', async function () { ) expect(result).toMatchInlineSnapshot(` + import { deepEquals } from "houdini/runtime"; + import { getCache } from "$houdini"; import { page } from "$app/state"; - import { extractSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; + import { extractSession, getClientSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; import { onMount } from "svelte"; import { setClientStarted } from "$houdini/plugins/houdini-svelte/runtime/adapter"; export let data + let houdini__session__initialized = false; onMount(() => setClientStarted()); $effect(() => { - setClientSession(extractSession(page.data)); + const nextSession = extractSession(page.data); + const sessionChanged = houdini__session__initialized && !deepEquals(getClientSession(), nextSession); + setClientSession(nextSession); + houdini__session__initialized = true; + + if (sessionChanged) { + getCache().refreshAll(nextSession); + } }); `) }) diff --git a/packages/houdini-svelte/package/vite/transform/init.ts b/packages/houdini-svelte/package/vite/transform/init.ts index 68b1b845a..8780f3cf2 100644 --- a/packages/houdini-svelte/package/vite/transform/init.ts +++ b/packages/houdini-svelte/package/vite/transform/init.ts @@ -25,12 +25,39 @@ export default async function kit_init(config: Config, page: SvelteTransformPage sourceModule: 'svelte', import: ['onMount'], }).ids[0] - const [extract_session, set_session] = ensure_imports({ + const [extract_session, get_client_session, set_session] = ensure_imports({ script: page.script, sourceModule: '$houdini/plugins/houdini-svelte/runtime/session', - import: ['extractSession', 'setClientSession'], + import: ['extractSession', 'getClientSession', 'setClientSession'], }).ids + // we need to track updates in page data as the client-side session + const page_store = ensure_imports({ + script: page.script, + sourceModule: '$app/state', + import: ['page'], + }).ids[0] + const get_cache = ensure_imports({ + script: page.script, + sourceModule: '$houdini', + import: ['getCache'], + }).ids[0] + const deep_equals = ensure_imports({ + script: page.script, + sourceModule: 'houdini/runtime', + import: ['deepEquals'], + }).ids[0] + + const session_initialized = AST.identifier('houdini__session__initialized') + const next_session = AST.identifier('nextSession') + const session_changed = AST.identifier('sessionChanged') + + page.script.body.push( + AST.variableDeclaration('let', [ + AST.variableDeclarator(session_initialized, AST.literal(false)), + ]) + ) + // add the onMount at the end of the component page.script.body.push( AST.expressionStatement( @@ -40,13 +67,6 @@ export default async function kit_init(config: Config, page: SvelteTransformPage ) ) - // we need to track updates in page data as the client-side session - const page_store = ensure_imports({ - script: page.script, - sourceModule: '$app/state', - import: ['page'], - }).ids[0] - // $effect dont get anyting in callback so we got to use store_page that is like page page.script.body.push( AST.expressionStatement( @@ -54,11 +74,46 @@ export default async function kit_init(config: Config, page: SvelteTransformPage AST.arrowFunctionExpression( [], AST.blockStatement([ - AST.expressionStatement( - AST.callExpression(set_session, [ + AST.variableDeclaration('const', [ + AST.variableDeclarator( + next_session, AST.callExpression(extract_session, [ AST.memberExpression(page_store, AST.identifier('data')), - ]), + ]) + ), + ]), + AST.variableDeclaration('const', [ + AST.variableDeclarator( + session_changed, + AST.logicalExpression( + '&&', + session_initialized, + AST.unaryExpression( + '!', + AST.callExpression(deep_equals, [ + AST.callExpression(get_client_session, []), + next_session, + ]) + ) + ) + ), + ]), + AST.expressionStatement(AST.callExpression(set_session, [next_session])), + AST.expressionStatement( + AST.assignmentExpression('=', session_initialized, AST.literal(true)) + ), + AST.ifStatement( + session_changed, + AST.blockStatement([ + AST.expressionStatement( + AST.callExpression( + AST.memberExpression( + AST.callExpression(get_cache, []), + AST.identifier('refreshAll') + ), + [next_session] + ) + ), ]) ), ]) diff --git a/packages/houdini-svelte/package/vite/transform/session.test.ts b/packages/houdini-svelte/package/vite/transform/session.test.ts index 3f7c83e69..dd168494e 100644 --- a/packages/houdini-svelte/package/vite/transform/session.test.ts +++ b/packages/houdini-svelte/package/vite/transform/session.test.ts @@ -14,15 +14,25 @@ test('modifies root +layout.svelte with data prop', async function () { ) expect(result).toMatchInlineSnapshot(` + import { deepEquals } from "houdini/runtime"; + import { getCache } from "$houdini"; import { page } from "$app/state"; - import { extractSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; + import { extractSession, getClientSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; import { onMount } from "svelte"; import { setClientStarted } from "$houdini/plugins/houdini-svelte/runtime/adapter"; export let data + let houdini__session__initialized = false; onMount(() => setClientStarted()); $effect(() => { - setClientSession(extractSession(page.data)); + const nextSession = extractSession(page.data); + const sessionChanged = houdini__session__initialized && !deepEquals(getClientSession(), nextSession); + setClientSession(nextSession); + houdini__session__initialized = true; + + if (sessionChanged) { + getCache().refreshAll(nextSession); + } }); `) }) @@ -54,14 +64,24 @@ test('modifies root +layout.svelte without data prop', async function () { const result = await test_transform_svelte('src/routes/+layout.svelte', ``) expect(result).toMatchInlineSnapshot(` + import { deepEquals } from "houdini/runtime"; + import { getCache } from "$houdini"; import { page } from "$app/state"; - import { extractSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; + import { extractSession, getClientSession, setClientSession } from "$houdini/plugins/houdini-svelte/runtime/session"; import { onMount } from "svelte"; import { setClientStarted } from "$houdini/plugins/houdini-svelte/runtime/adapter"; + let houdini__session__initialized = false; onMount(() => setClientStarted()); $effect(() => { - setClientSession(extractSession(page.data)); + const nextSession = extractSession(page.data); + const sessionChanged = houdini__session__initialized && !deepEquals(getClientSession(), nextSession); + setClientSession(nextSession); + houdini__session__initialized = true; + + if (sessionChanged) { + getCache().refreshAll(nextSession); + } }); `) }) diff --git a/packages/houdini/src/runtime/cache/index.ts b/packages/houdini/src/runtime/cache/index.ts index 6ba8d9304..0ffa5272f 100644 --- a/packages/houdini/src/runtime/cache/index.ts +++ b/packages/houdini/src/runtime/cache/index.ts @@ -233,6 +233,26 @@ export class Cache { } } + // ask every active query document to refetch itself. + // this is used when global request context changes (for example, the + // SvelteKit session) and any query might resolve differently even if its + // cached records did not change. non-query artifacts are skipped because + // only queries can be refetched through this cache notification path. + refreshAll(session?: App.Session | null) { + const message: { kind: 'refetch'; session?: App.Session | null } = + session === undefined ? { kind: 'refetch' } : { kind: 'refetch', session } + + for (const [spec] of this._internal_unstable.subscriptions.all({ + includeMaskedParents: true, + })) { + if (spec.kind !== ArtifactKind.Query) { + continue + } + + spec.onMessage(message) + } + } + markRecordStale(id: string, options: { field?: string; when?: {} }) { if (options.field) { const key = computeKey({ field: options.field, args: options.when ?? {} }) diff --git a/packages/houdini/src/runtime/cache/subscription.ts b/packages/houdini/src/runtime/cache/subscription.ts index fecfa944e..04397326b 100644 --- a/packages/houdini/src/runtime/cache/subscription.ts +++ b/packages/houdini/src/runtime/cache/subscription.ts @@ -348,6 +348,33 @@ export class InMemorySubscriptions { ) } + all({ includeMaskedParents = false }: { includeMaskedParents?: boolean } = {}) { + const result: FieldSelection[] = [] + const notified = new Set() + + for (const fields of this.subscribers.values()) { + for (const fieldSub of fields.values()) { + for (const selection of fieldSub.selections) { + if (!notified.has(selection[0].onMessage)) { + notified.add(selection[0].onMessage) + result.push(selection) + } + } + + if (includeMaskedParents) { + for (const selection of fieldSub.maskedParentSelections) { + if (!notified.has(selection[0].onMessage)) { + notified.add(selection[0].onMessage) + result.push(selection) + } + } + } + } + } + + return result + } + remove( id: string, selection: SubscriptionSelection, diff --git a/packages/houdini/src/runtime/types.ts b/packages/houdini/src/runtime/types.ts index c10d78191..4117027d9 100644 --- a/packages/houdini/src/runtime/types.ts +++ b/packages/houdini/src/runtime/types.ts @@ -319,6 +319,7 @@ export type CacheMessage<_Data = any> = } | { kind: 'refetch' + session?: App.Session | null } export type SubscriptionSpec = Readonly<{