diff --git a/.changeset/rotten-timers-exist.md b/.changeset/rotten-timers-exist.md new file mode 100644 index 0000000000..008a9c00be --- /dev/null +++ b/.changeset/rotten-timers-exist.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +Fix bug when updating deeply nested lists with @parentID diff --git a/e2e/_api/graphql.mjs b/e2e/_api/graphql.mjs index 0fc0070b50..8ee4c8d86b 100644 --- a/e2e/_api/graphql.mjs +++ b/e2e/_api/graphql.mjs @@ -10,6 +10,41 @@ export const typeDefs = sourceFiles.map((filepath) => fs.readFileSync(path.resolve(filepath), 'utf-8') ) +// Example Cities/Libraries/Books data +// Assume a traditional relational database for storage - each table with unique ID. +let cityId = 1; +let libraryId = 1; +let bookId = 1; + +// Allow the "database" to be persistent and mutable +let cities = [ + { + id: cityId++, name: 'Alexandria', libraries: [ + { + id: libraryId++, name: 'The Library of Alexandria', books: [ + { id: bookId++, title: 'Callimachus Pinakes' }, + { id: bookId++, title: 'Kutubkhana-i-lskandriyya' }, + ] + }, + { + id: libraryId++, name: 'Bibliotheca Alexandrina', books: [ + { id: bookId++, title: 'Analyze your own personality' }, + ] + }, + ] + }, + { + id: cityId++, name: 'Istanbul', libraries: [ + { + id: libraryId++, name: 'The Imperial Library of Constantinople', books: [ + { id: bookId++, title: 'Homer' }, + { id: bookId++, title: 'The Hellenistic History' }, + ] + }, + ] + }, +]; + // example data const data = [ { id: '1', name: 'Bruce Willis', birthDate: new Date(1955, 2, 19) }, @@ -100,6 +135,9 @@ export const resolvers = { __typename: 'User', } }, + cities: () => { + return cities; + }, }, User: { @@ -149,7 +187,7 @@ export const resolvers = { try { let data = await processFile(file) return data - } catch (e) {} + } catch (e) { } throw new GraphQLYogaError('ERROR', { code: 500 }) }, multipleUpload: async (_, { files }) => { @@ -164,6 +202,73 @@ export const resolvers = { } return res }, + addCity: (_, args) => { + const city = { + id: cityId++, + name: args.name, + libraries: [], + } + + cities.push(city); + return city; + }, + addLibrary: (_, args) => { + const cityId = Number.parseInt(args.city); + const city = cities.find((city) => city.id === cityId); + if (!city) { + throw new GraphQLYogaError('City not found', { code: 404 }) + } + + const library = { + id: libraryId++, + name: args.name, + books: [], + } + city.libraries.push(library); + return library; + }, + addBook: (_, args) => { + const libraryId = Number.parseInt(args.library); + const city = cities.find((city) => city.libraries.find((library) => library.id === libraryId)); + if (!city) { + throw new GraphQLYogaError('City/Library not found', { code: 404 }) + } + const library = city.libraries.find((library) => library.id === libraryId); + + const book = { + id: bookId++, + title: args.title, + } + library.books.push(book); + return book; + }, + deleteCity: (_, args) => { + const cityId = Number.parseInt(args.city); + const city = cities.find((city) => city.id === cityId); + cities = cities.filter((city) => city.id !== cityId); + return city; + }, + deleteLibrary: (_, args) => { + const libraryId = Number.parseInt(args.library); + const city = cities.find((city) => city.libraries.find((library) => library.id === libraryId)); + if (!city) { + throw new GraphQLYogaError('City/Library not found', { code: 404 }) + } + const library = city.libraries.find((library) => library.id === libraryId); + city.libraries = city.libraries.filter((library) => library.id !== libraryId); + return library; + }, + deleteBook: (_, args) => { + const bookId = Number.parseInt(args.book); + const city = cities.find((city) => city.libraries.find((library) => library.books.find((book) => book.id === bookId))); + if (!city) { + throw new GraphQLYogaError('City/Library/Book not found', { code: 404 }) + } + const library = city.libraries.find((library) => library.books.find((book) => book.id === bookId)); + const book = library.books.find((book) => book.id === bookId); + library.books = library.books.filter((book) => book.id !== bookId); + return book; + }, }, DateTime: new GraphQLScalarType({ diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql index 7467df3987..df08501c06 100644 --- a/e2e/_api/schema.graphql +++ b/e2e/_api/schema.graphql @@ -26,6 +26,26 @@ type Mutation { updateUser(id: ID!, name: String, snapshot: String!, birthDate: DateTime, delay: Int): User! singleUpload(file: File!): String! multipleUpload(files: [File!]!): [String!]! + addCity( + name: String! + ): City! + addLibrary( + city: ID! + name: String! + ): Library! + addBook( + library: ID! + title: String! + ): Book! + deleteCity( + city: ID! + ): City! + deleteLibrary( + library: ID! + ): Library! + deleteBook( + book: ID! + ): Book! } interface Node { @@ -52,6 +72,7 @@ type Query { ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! session: String + cities: [City]! } type User implements Node { @@ -73,3 +94,21 @@ type UserEdge { cursor: String node: User } + + +type Book { + id: ID! + title: String! +} + +type Library { + id: ID! + name: String! + books: [Book]! +} + +type City { + id: ID! + name: String! + libraries: [Library]! +} \ No newline at end of file diff --git a/e2e/sveltekit/src/lib/utils/routes.ts b/e2e/sveltekit/src/lib/utils/routes.ts index 86f053c005..0cc1bc11cd 100644 --- a/e2e/sveltekit/src/lib/utils/routes.ts +++ b/e2e/sveltekit/src/lib/utils/routes.ts @@ -56,6 +56,8 @@ export const routes = { Pagination_fragment_backwards_cursor: '/pagination/fragment/backwards-cursor', Pagination_fragment_offset: '/pagination/fragment/offset', + Stores_Nested_List: '/stores/nested-list', + Stores_subunsub_list: '/stores/subunsub-list', Stores_subunsub_mutation: '/stores/subunsub-mutation', diff --git a/e2e/sveltekit/src/routes/stores/nested-list/+page.svelte b/e2e/sveltekit/src/routes/stores/nested-list/+page.svelte new file mode 100644 index 0000000000..f645063aca --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/+page.svelte @@ -0,0 +1,98 @@ + + +

Nested - List

+ + + +
{JSON.stringify($GQL_Cities?.data, null, 4)}
diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddBook.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddBook.gql new file mode 100644 index 0000000000..4cdf9211c7 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddBook.gql @@ -0,0 +1,6 @@ +mutation AddBook($library: ID!, $title: String!) { + addBook(library: $library, title: $title) { + id + ...Book_List_insert @append(parentID: $library) + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddCity.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddCity.gql new file mode 100644 index 0000000000..8a94c88c68 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddCity.gql @@ -0,0 +1,6 @@ +mutation AddCity($name: String!) { + addCity(name: $name) { + id + ...City_List_insert + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddLibrary.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddLibrary.gql new file mode 100644 index 0000000000..63e61e4b98 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.AddLibrary.gql @@ -0,0 +1,6 @@ +mutation AddLibrary($city: ID!, $name: String!) { + addLibrary(city: $city, name: $name) { + id + ...Library_List_insert @append(parentID: $city) + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteBook.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteBook.gql new file mode 100644 index 0000000000..480cd6b517 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteBook.gql @@ -0,0 +1,5 @@ +mutation DeleteBook($book: ID!) { + deleteBook(book: $book) { + id @Book_delete + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteCity.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteCity.gql new file mode 100644 index 0000000000..f14b960c15 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteCity.gql @@ -0,0 +1,5 @@ +mutation DeleteCity($city: ID!) { + deleteCity(city: $city) { + id @City_delete + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteLibrary.gql b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteLibrary.gql new file mode 100644 index 0000000000..a0ffc88195 --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/MUTATION.DeleteLibrary.gql @@ -0,0 +1,5 @@ +mutation DeleteLibrary($library: ID!) { + deleteLibrary(library: $library) { + id @Library_delete + } +} diff --git a/e2e/sveltekit/src/routes/stores/nested-list/QUERY.Cities.gql b/e2e/sveltekit/src/routes/stores/nested-list/QUERY.Cities.gql new file mode 100644 index 0000000000..546b8dc57b --- /dev/null +++ b/e2e/sveltekit/src/routes/stores/nested-list/QUERY.Cities.gql @@ -0,0 +1,14 @@ +query Cities { + cities @list(name: "City_List") { + id + name + libraries @list(name: "Library_List") { + id + name + books @list(name: "Book_List") { + id + title + } + } + } +} diff --git a/packages/houdini/src/runtime/cache/cache.ts b/packages/houdini/src/runtime/cache/cache.ts index 1472989d19..561f352598 100644 --- a/packages/houdini/src/runtime/cache/cache.ts +++ b/packages/houdini/src/runtime/cache/cache.ts @@ -357,6 +357,7 @@ class CacheInternal { selection: fields, subscribers: currentSubscribers, variables, + parentType: linkedType, }) toNotify.push(...currentSubscribers) @@ -545,6 +546,7 @@ class CacheInternal { selection: fields, subscribers: currentSubscribers, variables, + parentType: linkedType, }) } } diff --git a/packages/houdini/src/runtime/cache/subscription.ts b/packages/houdini/src/runtime/cache/subscription.ts index 58a2f4c57c..9d4e06a6f3 100644 --- a/packages/houdini/src/runtime/cache/subscription.ts +++ b/packages/houdini/src/runtime/cache/subscription.ts @@ -156,16 +156,17 @@ export class InMemorySubscriptions { selection, variables, subscribers, + parentType, }: { parent: string selection: SubscriptionSelection variables: {} subscribers: SubscriptionSpec[] + parentType: string }) { // look at every field in the selection and add the subscribers for (const fieldSelection of Object.values(selection)) { - const { keyRaw, fields } = fieldSelection - + const { type: linkedType, keyRaw, fields } = fieldSelection const key = evaluateKey(keyRaw, variables) // add the subscriber to the @@ -175,7 +176,7 @@ export class InMemorySubscriptions { key, selection: fieldSelection, spec, - parentType: 'asdf', + parentType, variables, }) } @@ -183,6 +184,7 @@ export class InMemorySubscriptions { // if there are fields under this if (fields) { const { value: link } = this.cache._internal_unstable.storage.get(parent, key) + // figure out who else needs subscribers const children = !Array.isArray(link) ? ([link] as string[]) @@ -192,13 +194,13 @@ export class InMemorySubscriptions { if (!linkedRecord) { continue } - // insert the subscriber this.addMany({ parent: linkedRecord, selection: fields, variables, subscribers, + parentType: linkedType, }) } } diff --git a/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts b/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts index 84051a7b97..9a29f9220b 100644 --- a/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts +++ b/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts @@ -1,6 +1,7 @@ import { test, expect, vi } from 'vitest' import { testConfigFile } from '../../../test' +import { RefetchUpdateMode, SubscriptionSelection } from '../../lib' import { Cache } from '../cache' const config = testConfigFile() @@ -1637,6 +1638,120 @@ test('clearing a display layer updates subscribers', function () { }) }) +test('ensure parent type is properly passed for nested lists', function () { + // instantiate a cache + const cache = new Cache(config) + + const selection: SubscriptionSelection = { + cities: { + type: 'City', + keyRaw: 'cities', + list: { + name: 'City_List', + connection: false, + type: 'City', + }, + update: RefetchUpdateMode.append, + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + name: { + type: 'String', + keyRaw: 'name', + }, + libraries: { + type: 'Library', + keyRaw: 'libraries', + update: RefetchUpdateMode.append, + list: { + name: 'Library_List', + connection: false, + type: 'Library', + }, + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + name: { + type: 'String', + keyRaw: 'name', + }, + books: { + type: 'Book', + keyRaw: 'books', + list: { + name: 'Book_List', + connection: false, + type: 'Book', + }, + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + title: { + type: 'String', + keyRaw: 'title', + }, + }, + }, + }, + }, + }, + }, + } + + // a function to spy on that will play the role of set + const set = vi.fn() + + // subscribe to the fields + cache.subscribe({ + rootType: 'Query', + selection, + set, + }) + + // add a city to the list by hand since using the list util adds type information + + cache.write({ + selection, + data: { + cities: [ + { + id: '1', + name: 'Alexandria', + libraries: [ + { + id: '1', + name: 'The Library of Alexandria', + books: [], + }, + { + id: '2', + name: 'Bibliotheca Alexandrina', + books: [], + }, + ], + }, + { + id: '2', + name: 'Aalborg', + libraries: [], + }, + ], + }, + }) + + // since there are multiple lists inside of City_List, we need to + // specify the parentID of the city in order to add a library to City:3 + expect(() => cache.list('Library_List', '2')).not.toThrow() + // same with Books_List for Library:2 + expect(() => cache.list('Book_List', '2')).not.toThrow() +}) + test.todo('can write to and resolve layers') test.todo("resolving a layer with the same value as the most recent doesn't notify subscribers")