Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/lucky-moons-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'druxt': patch
---

fix(#781): `getCollection` now returns fully hydrated resources in `included`
on a cache hit, matching what a fresh fetch returns, where it previously
returned bare `{ id, type }` references. A resource removed with
`flushResource` is fetched again on the next collection request instead of
coming back as an undefined entry, and a cached collection returns only the
includes its most recent response carried.
26 changes: 20 additions & 6 deletions packages/druxt/src/stores/druxt.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,20 @@ const DruxtStore = ({ store }) => {
// Store and dehydrate collection resources.
collection.data = dehydrateResources({ commit: this.commit, prefix, queryObject, resources: collection.data })

// Extract and store included resources.
// Keep the dehydrated refs (don't delete) so a cache hit in
// getCollection can re-hydrate `included`, same as `data`.
if (collection.included) {
collection.included = dehydrateResources({ commit: this.commit, prefix, queryObject, resources: collection.included })
delete collection.included
}

// Recursively merge new collection data into stored collection.
// The hash ignores `include`, so queries that differ only by their
// includes share a slot. deepmerge keeps a key the incoming response
// does not carry, which would leave the previous query's `included`
// refs behind for getCollection to hydrate and return unasked for.
const hadIncluded = !!collection.included
collection = merge(state.collections[type][hash][prefix] || {}, collection, { arrayMerge: (dst, src) => src })
if (!hadIncluded) delete collection.included

Vue.set(state.collections[type][hash], prefix, collection)
},
Expand Down Expand Up @@ -216,10 +222,18 @@ const DruxtStore = ({ store }) => {

// If collection hash exists, re-hydrate and return the data.
if (!bypassCache && ((state.collections[type] || {})[hash] || {})[prefix]) {
return {
...state.collections[type][hash][prefix],
// Hydrate resource data.
data: state.collections[type][hash][prefix].data.map((o) => ((state.resources[o.type][o.id] || {})[prefix] || {}).data)
const cached = state.collections[type][hash][prefix]
const hydrate = (o) => (((state.resources[o.type] || {})[o.id] || {})[prefix] || {}).data
const data = cached.data.map(hydrate)
const included = cached.included ? cached.included.map(hydrate) : undefined
// A ref with no resource behind it means flushResource ran since the
// collection was stored; treat the hit as a miss and fetch again.
if (data.every((o) => o) && (included || []).every((o) => o)) {
return {
...cached,
data,
...(included ? { included } : {}),
}
}
}

Expand Down
81 changes: 79 additions & 2 deletions packages/druxt/test/stores/druxt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ describe('DruxtStore', () => {
expect.objectContaining({ id, type: 'node--page' })
)

// Expect the collection be stored without included data.
expect(store.state.druxt.collections['node--page']._default[undefined].included).toBeFalsy()
// Expect the collection be stored with dehydrated (not dropped)
// included resources, so a later cache hit can re-hydrate `included`
// the same way it re-hydrates `data`.
expect(store.state.druxt.collections['node--page']._default[undefined].included[0]).toStrictEqual(
expect.objectContaining({ id: included[0].id, type: 'node--article' })
)
})

test('addResource', async () => {
Expand Down Expand Up @@ -299,6 +303,79 @@ describe('DruxtStore', () => {
expect(mockAxios.get).toHaveBeenCalledTimes(2)
})

test('getCollection cache hit re-hydrates included data', async () => {
const mockCollectionPage = await getMockCollection('node--page')
const includedId = 'included-article-uuid'
store.commit('druxt/addCollection', {
collection: {
...mockCollectionPage,
included: [{ type: 'node--article', id: includedId, attributes: { title: 'Included' } }],
},
type: 'node--page',
hash: '_default',
})

// A cache hit must return `included` the same way a fresh fetch would.
const cached = await store.dispatch('druxt/getCollection', { type: 'node--page' })
expect(cached.included).toHaveLength(1)
expect(cached.included[0]).toStrictEqual(
expect.objectContaining({ id: includedId, type: 'node--article' })
)
// The stored entry is a bare `{ id, type }` ref with no attributes, so
// a hydrated resource is the only thing that can satisfy this.
expect(cached.included[0].attributes).toStrictEqual({ title: 'Included' })
})

test('getCollection fetches again after flushResource', async () => {
const type = 'node--page'
const mockCollectionPage = await getMockCollection(type)
store.commit('druxt/addCollection', {
collection: {
...mockCollectionPage,
included: [{ type: 'node--article', id: 'flushed-article-uuid', attributes: { title: 'Included' } }],
},
type,
hash: '_default',
})

// A cache hit while the resources are still stored doesn't request anything.
await store.dispatch('druxt/getCollection', { type })
expect(mockAxios.get).toHaveBeenCalledTimes(0)

// Every resource bucket goes, while the collection entry stays, so the
// next request is a fetch rather than a collection of undefined entries.
store.commit('druxt/flushResource', {})
const fresh = await store.dispatch('druxt/getCollection', { type })
// The JSON:API index request and the collection request.
expect(mockAxios.get).toHaveBeenCalledTimes(2)
expect(fresh.data).toStrictEqual(mockCollectionPage.data)
expect(fresh.data.every((o) => o)).toBe(true)
})

test('addCollection drops included when the response omits it', async () => {
const type = 'node--page'
const hash = '_default'
const mockCollectionPage = await getMockCollection(type)

store.commit('druxt/addCollection', {
collection: {
...mockCollectionPage,
included: [{ type: 'node--article', id: 'stale-article-uuid', attributes: { title: 'Included' } }],
},
type,
hash,
})
expect(store.state.druxt.collections[type][hash][undefined].included).toHaveLength(1)

// The hash ignores `include`, so the same slot takes a response from a
// query that asked for none. The previous refs must not survive it.
store.commit('druxt/addCollection', { collection: { ...mockCollectionPage }, type, hash })
expect(store.state.druxt.collections[type][hash][undefined].included).toBeUndefined()

const cached = await store.dispatch('druxt/getCollection', { type })
expect(cached.included).toBeUndefined()
})

test('flushCollection', async () => {
const type = 'node--page'
const hash ='_default'
Expand Down
Loading