Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apollo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ let users = []
let idIncrement = 0
let files: UploadedFile[] = []
let formSubmissions: FormSubmission[] = []
let newsQueryCount = 0

// Dummy content data
const articles: Article[] = [
Expand Down Expand Up @@ -169,6 +170,73 @@ const pages: Page[] = [
},
]

// News mock data (org IDs: 1=Parks, 2=Roads, 3=Culture, 5=Sports)
const newsItems = [
{
title: 'City Park Gets New Playground',
lead: 'A modern playground opens next month.',
url: { path: '/news/park-playground' },
date: '2026-03-15',
organizationId: '1',
},
{
title: 'Community Garden Expansion',
lead: 'Fifty new plots available for residents.',
url: { path: '/news/garden-expansion' },
date: '2026-03-20',
organizationId: '1',
},
{
title: 'Main Street Roadwork Begins',
lead: 'Expect detours starting April.',
url: { path: '/news/roadwork' },
date: '2026-04-01',
organizationId: '2',
},
{
title: 'New Bike Lane Opens',
lead: 'Connecting the north and south districts.',
url: { path: '/news/bike-lane' },
date: '2026-03-28',
organizationId: '2',
},
{
title: 'Annual Culture Night 2026',
lead: 'Museums and galleries open late on May 15.',
url: { path: '/news/culture-night' },
date: '2026-04-05',
organizationId: '3',
},
{
title: 'New Art Exhibition Downtown',
lead: 'Contemporary works from local artists.',
url: { path: '/news/art-exhibition' },
date: '2026-04-02',
organizationId: '3',
},
{
title: 'Public Pool Season Starts',
lead: 'Outdoor pools open May 1st.',
url: { path: '/news/pool-season' },
date: '2026-04-08',
organizationId: '5',
},
{
title: 'Weekend Sports Festival',
lead: 'Free activities for all ages at the stadium.',
url: { path: '/news/sports-festival' },
date: '2026-03-25',
organizationId: '5',
},
{
title: 'Youth Soccer Tournament',
lead: 'Local teams compete for the cup.',
url: { path: '/news/soccer-tournament' },
date: '2026-04-06',
organizationId: '5',
},
]

function getAllContent(): Content[] {
return [...articles, ...blogPosts, ...pages]
}
Expand Down Expand Up @@ -390,6 +458,27 @@ const typeDefs = `#graphql
languageFromPath: String
}

type Organization {
id: String!
name: String!
}

type NewsUrl {
path: String
}

type NewsItem {
title: String
lead: String
url: NewsUrl
date: String
organizationId: String!
}

type NewsEntityQueryResult {
items: [NewsItem]
}

type Query {
foobar: String
"""
Expand Down Expand Up @@ -480,6 +569,12 @@ const typeDefs = `#graphql
Search across all content types and users.
"""
search(query: String!): [SearchResult!]!

"""
Get news items, optionally filtered by organization IDs.
Returns all news if ouIds is null.
"""
newsEntityQuery(ouIds: [String], limit: Int): NewsEntityQueryResult!
}

type UploadedFile {
Expand Down Expand Up @@ -661,6 +756,22 @@ const resolvers = {

return results
},
newsEntityQuery: (
_: any,
args: { ouIds?: string[] | null; limit?: number | null },
) => {
newsQueryCount++
let items = [...newsItems]
if (args.ouIds && args.ouIds.length > 0) {
items = items.filter((item) =>
args.ouIds!.includes(item.organizationId),
)
}
if (args.limit && args.limit > 0) {
items = items.slice(0, args.limit)
}
return { items }
},
},

// Interface type resolver
Expand Down Expand Up @@ -822,6 +933,12 @@ const server = new ApolloServer({
})
await server.start()

app.get('/news-query-count', cors(), (_req, res) => {
const count = newsQueryCount
newsQueryCount = 0
res.json({ count })
})

app.use(
'/',
cors(),
Expand Down
80 changes: 80 additions & 0 deletions cypress/e2e/debugRefetch.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
describe('useAsyncGraphqlQuery refetch with multiple instances', () => {
beforeEach(() => {
cy.visit('/debug-refetch').waitForHydration()
})

it('both instances initially show Sports (org "5") data', () => {
cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
cy.get('[data-actual-org-ids="b"]').should('contain', '5')
})

it('changing Instance B orgs to "3" refetches with Culture data', () => {
cy.get('#set-orgs-3').click()

// Instance B should refetch and show Culture (org "3") data.
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '3')
cy.get('[data-actual-org-ids="b"]').should('not.contain', '5')

// Instance A should still show Sports (org "5") data.
cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
})

it('changing Instance B orgs to "1" refetches with Parks data', () => {
cy.get('#set-orgs-1').click()

cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '1')
cy.get('[data-actual-org-ids="b"]').should('not.contain', '5')

cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
})

it('changing Instance B orgs to multiple IDs refetches correctly', () => {
cy.get('#set-orgs-2-3').click()

cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('not.contain', '5')

cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
})

it('resetting Instance B orgs to empty returns to context data', () => {
// First change to Culture.
cy.get('#set-orgs-3').click()
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '3')

// Reset to empty — should fall back to context (org "5").
cy.get('#set-orgs-empty').click()
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '5')

cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
})

it('switching Instance B through multiple org changes keeps data fresh', () => {
// Sports (context) -> Culture -> Parks -> back to context
cy.get('#set-orgs-3').click()
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '3')

cy.get('#set-orgs-1').click()
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '1')

cy.get('#set-orgs-empty').click()
cy.get('[data-status="b"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="b"]').should('contain', '5')

// Instance A must remain untouched throughout.
cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-actual-org-ids="a"]').should('contain', '5')
})
})
20 changes: 20 additions & 0 deletions cypress/e2e/ssrDeduplication.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
describe('SSR request deduplication', () => {
it('only makes one GraphQL request when two instances use the same query and variables', () => {
// Reset the counter.
cy.request('http://localhost:4000/news-query-count')

// Visit the page via SSR (two DebugNewsList instances, both with
// the same initial variables: ouIds=["5"], limit=3).
cy.visit('/debug-refetch').waitForHydration()

// Both instances should have rendered with data.
cy.get('[data-status="a"]').should('have.text', 'OK')
cy.get('[data-status="b"]').should('have.text', 'OK')

// The Apollo server should have received exactly one request,
// not two, because the SSR deduplication should share the promise.
cy.request('http://localhost:4000/news-query-count').then((response) => {
expect(response.body.count).to.equal(1)
})
})
})
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
modules: ['@nuxtjs/mcp-toolkit', '@nuxt/devtools'],
nitro: {
typescript: {
tsConfig: {
Expand Down
Loading
Loading