diff --git a/packages/media-utils/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts b/packages/media-utils/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts index 92d7b4b96e56b3..6bd3a56c465d9c 100644 --- a/packages/media-utils/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts +++ b/packages/media-utils/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts @@ -18,9 +18,13 @@ * WordPress dependencies */ import { useCallback } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; import { useRegistry } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { invalidateAttachmentResolutions } from '../../utils/invalidate-attachment-resolutions'; + /** * Returns a stable callback that invalidates all cached `getEntityRecords` * resolutions for `postType / attachment`, leaving every other entity type @@ -29,22 +33,8 @@ import { useRegistry } from '@wordpress/data'; export function useInvalidateAttachmentResolutions() { const registry = useRegistry(); - return useCallback( () => { - const resolvers = registry.select( coreStore ).getCachedResolvers(); - - // getCachedResolvers() is typed as Record but the - // values are EquivalentKeyMap instances (Map-like). Cast the same - // way the resolvers-cache-middleware does internally. - const entityRecordResolutions = resolvers.getEntityRecords as - | Map< string[], { status: string } > - | undefined; - - entityRecordResolutions?.forEach( ( _value, args ) => { - if ( args[ 0 ] === 'postType' && args[ 1 ] === 'attachment' ) { - registry - .dispatch( coreStore ) - .invalidateResolution( 'getEntityRecords', args ); - } - } ); - }, [ registry ] ); + return useCallback( + () => invalidateAttachmentResolutions( registry ), + [ registry ] + ); } diff --git a/packages/media-utils/src/components/media-upload/index.js b/packages/media-utils/src/components/media-upload/index.js index c1b5187be58fc4..678f92d78b6d84 100644 --- a/packages/media-utils/src/components/media-upload/index.js +++ b/packages/media-utils/src/components/media-upload/index.js @@ -3,6 +3,12 @@ */ import { Component } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { invalidateAttachmentResolutions } from '../../utils/invalidate-attachment-resolutions'; const DEFAULT_EMPTY_GALLERY = []; @@ -482,6 +488,23 @@ class MediaUpload extends Component { onClose(); } + // Uploads performed inside the legacy wp.media modal attach media to the + // current post but never reach @wordpress/core-data, so views listing + // attached media (e.g. the Gallery block's dynamic mode) would stay stale + // until the editor reloads. Invalidate the cached attachment queries on + // close so they refetch — mirroring the core-data-native media modal, + // which does the same on upload completion. + // + // This targets the default registry via the module-level `select` and + // `dispatch` rather than the contextual one. As a class component + // extended by plugins through the `editor.MediaUpload` filter, it can't + // read the contextual registry without `withRegistry`/`useRegistry`, + // which would break that subclassing contract (see + // https://github.com/WordPress/gutenberg/pull/70648). The editor runs on + // the default registry in practice, so the only case this misses is a + // custom registry, where the invalidation is effectively a no-op. + invalidateAttachmentResolutions( { select, dispatch } ); + this.frame.detach(); } diff --git a/packages/media-utils/src/components/media-upload/test/index.js b/packages/media-utils/src/components/media-upload/test/index.js new file mode 100644 index 00000000000000..3334cf4c30048a --- /dev/null +++ b/packages/media-utils/src/components/media-upload/test/index.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import MediaUpload from '../index'; +import { invalidateAttachmentResolutions } from '../../../utils/invalidate-attachment-resolutions'; + +jest.mock( '../../../utils/invalidate-attachment-resolutions' ); + +describe( 'MediaUpload', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'onClose', () => { + it( 'invalidates the cached attachment resolutions against the default registry', () => { + const instance = new MediaUpload( { onClose: jest.fn() } ); + // `onClose` detaches the underlying wp.media frame; stub it so the + // method can run without the global media library being present. + instance.frame = { detach: jest.fn() }; + + instance.onClose(); + + expect( invalidateAttachmentResolutions ).toHaveBeenCalledTimes( + 1 + ); + expect( invalidateAttachmentResolutions ).toHaveBeenCalledWith( { + select, + dispatch, + } ); + // The frame is still detached after invalidating. + expect( instance.frame.detach ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'invalidates even when no onClose prop is provided', () => { + const instance = new MediaUpload( {} ); + instance.frame = { detach: jest.fn() }; + + instance.onClose(); + + expect( invalidateAttachmentResolutions ).toHaveBeenCalledTimes( + 1 + ); + expect( invalidateAttachmentResolutions ).toHaveBeenCalledWith( { + select, + dispatch, + } ); + } ); + + it( 'calls the onClose prop before detaching the frame', () => { + const onClose = jest.fn(); + const instance = new MediaUpload( { onClose } ); + instance.frame = { detach: jest.fn() }; + + instance.onClose(); + + expect( onClose ).toHaveBeenCalledTimes( 1 ); + expect( instance.frame.detach ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/packages/media-utils/src/utils/invalidate-attachment-resolutions.ts b/packages/media-utils/src/utils/invalidate-attachment-resolutions.ts new file mode 100644 index 00000000000000..c9e7d2ddad2f5f --- /dev/null +++ b/packages/media-utils/src/utils/invalidate-attachment-resolutions.ts @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import type { DataRegistry } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Invalidates every cached `getEntityRecords` resolution for the `attachment` + * post type, so components listing media attached to the current post (e.g. the + * Gallery block's dynamic mode) re-resolve against the server after an upload. + * + * `invalidateResolution` only clears the exact query passed to it — on a + * paginated grid, page 1 (where a new upload lands) would stay stale — while + * `invalidateResolutionForStoreSelector` clears every `getEntityRecords` + * resolution, unrelated entity types included. This walks the cached + * resolutions and invalidates only the `['postType', 'attachment', …]` entries. + * + * @param registry A `@wordpress/data` registry (`useRegistry()`), or the + * default-registry `{ select, dispatch }` exports. + */ +export function invalidateAttachmentResolutions( + registry: Pick< DataRegistry, 'select' | 'dispatch' > +) { + const resolvers = registry.select( coreStore ).getCachedResolvers(); + + // getCachedResolvers() is typed as Record but the values + // are EquivalentKeyMap instances (Map-like). Cast the same way the + // resolvers-cache-middleware does internally. + const entityRecordResolutions = resolvers.getEntityRecords as + | Map< string[], { status: string } > + | undefined; + + entityRecordResolutions?.forEach( ( _value, args ) => { + if ( args[ 0 ] === 'postType' && args[ 1 ] === 'attachment' ) { + registry + .dispatch( coreStore ) + .invalidateResolution( 'getEntityRecords', args ); + } + } ); +} diff --git a/packages/media-utils/src/utils/test/invalidate-attachment-resolutions.ts b/packages/media-utils/src/utils/test/invalidate-attachment-resolutions.ts new file mode 100644 index 00000000000000..ad3509240ca3a1 --- /dev/null +++ b/packages/media-utils/src/utils/test/invalidate-attachment-resolutions.ts @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import { invalidateAttachmentResolutions } from '../invalidate-attachment-resolutions'; + +type Registry = Parameters< typeof invalidateAttachmentResolutions >[ 0 ]; + +/** + * Builds a minimal registry stub whose `getCachedResolvers().getEntityRecords` + * returns the supplied map, and captures the `invalidateResolution` calls. + * + * @param entityRecords The cached `getEntityRecords` resolutions (or undefined). + */ +function createRegistryStub( + entityRecords: Map< unknown[], { status: string } > | undefined +) { + const invalidateResolution = jest.fn(); + const registry = { + select: () => ( { + getCachedResolvers: () => ( { getEntityRecords: entityRecords } ), + } ), + dispatch: () => ( { invalidateResolution } ), + } as unknown as Registry; + + return { registry, invalidateResolution }; +} + +describe( 'invalidateAttachmentResolutions', () => { + it( 'invalidates only the postType/attachment getEntityRecords resolutions', () => { + const attachmentPage1 = [ 'postType', 'attachment', { parent: 1 } ]; + const attachmentPage2 = [ + 'postType', + 'attachment', + { parent: 1, page: 2 }, + ]; + const postQuery = [ 'postType', 'post', { per_page: 10 } ]; + const rootMediaQuery = [ 'root', 'media', {} ]; + + const { registry, invalidateResolution } = createRegistryStub( + new Map( [ + [ attachmentPage1, { status: 'finished' } ], + [ attachmentPage2, { status: 'finished' } ], + [ postQuery, { status: 'finished' } ], + [ rootMediaQuery, { status: 'finished' } ], + ] ) + ); + + invalidateAttachmentResolutions( registry ); + + // Both attachment queries (including the paginated one) are invalidated, + // with their exact args, and nothing else is touched. + expect( invalidateResolution ).toHaveBeenCalledTimes( 2 ); + expect( invalidateResolution ).toHaveBeenCalledWith( + 'getEntityRecords', + attachmentPage1 + ); + expect( invalidateResolution ).toHaveBeenCalledWith( + 'getEntityRecords', + attachmentPage2 + ); + expect( invalidateResolution ).not.toHaveBeenCalledWith( + 'getEntityRecords', + postQuery + ); + expect( invalidateResolution ).not.toHaveBeenCalledWith( + 'getEntityRecords', + rootMediaQuery + ); + } ); + + it( 'does nothing when there are no cached getEntityRecords resolutions', () => { + const { registry, invalidateResolution } = + createRegistryStub( undefined ); + + invalidateAttachmentResolutions( registry ); + + expect( invalidateResolution ).not.toHaveBeenCalled(); + } ); +} );