fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

199
node_modules/@wordpress/api-fetch/src/index.js generated vendored Normal file
View File

@@ -0,0 +1,199 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import createNonceMiddleware from './middlewares/nonce';
import createRootURLMiddleware from './middlewares/root-url';
import createPreloadingMiddleware from './middlewares/preloading';
import fetchAllMiddleware from './middlewares/fetch-all-middleware';
import namespaceEndpointMiddleware from './middlewares/namespace-endpoint';
import httpV1Middleware from './middlewares/http-v1';
import userLocaleMiddleware from './middlewares/user-locale';
import mediaUploadMiddleware from './middlewares/media-upload';
import createThemePreviewMiddleware from './middlewares/theme-preview';
import {
parseResponseAndNormalizeError,
parseAndThrowError,
} from './utils/response';
/**
* Default set of header values which should be sent with every request unless
* explicitly provided through apiFetch options.
*
* @type {Record<string, string>}
*/
const DEFAULT_HEADERS = {
// The backend uses the Accept header as a condition for considering an
// incoming request as a REST request.
//
// See: https://core.trac.wordpress.org/ticket/44534
Accept: 'application/json, */*;q=0.1',
};
/**
* Default set of fetch option values which should be sent with every request
* unless explicitly provided through apiFetch options.
*
* @type {Object}
*/
const DEFAULT_OPTIONS = {
credentials: 'include',
};
/** @typedef {import('./types').APIFetchMiddleware} APIFetchMiddleware */
/** @typedef {import('./types').APIFetchOptions} APIFetchOptions */
/**
* @type {import('./types').APIFetchMiddleware[]}
*/
const middlewares = [
userLocaleMiddleware,
namespaceEndpointMiddleware,
httpV1Middleware,
fetchAllMiddleware,
];
/**
* Register a middleware
*
* @param {import('./types').APIFetchMiddleware} middleware
*/
function registerMiddleware( middleware ) {
middlewares.unshift( middleware );
}
/**
* Checks the status of a response, throwing the Response as an error if
* it is outside the 200 range.
*
* @param {Response} response
* @return {Response} The response if the status is in the 200 range.
*/
const checkStatus = ( response ) => {
if ( response.status >= 200 && response.status < 300 ) {
return response;
}
throw response;
};
/** @typedef {(options: import('./types').APIFetchOptions) => Promise<any>} FetchHandler*/
/**
* @type {FetchHandler}
*/
const defaultFetchHandler = ( nextOptions ) => {
const { url, path, data, parse = true, ...remainingOptions } = nextOptions;
let { body, headers } = nextOptions;
// Merge explicitly-provided headers with default values.
headers = { ...DEFAULT_HEADERS, ...headers };
// The `data` property is a shorthand for sending a JSON body.
if ( data ) {
body = JSON.stringify( data );
headers[ 'Content-Type' ] = 'application/json';
}
const responsePromise = window.fetch(
// Fall back to explicitly passing `window.location` which is the behavior if `undefined` is passed.
url || path || window.location.href,
{
...DEFAULT_OPTIONS,
...remainingOptions,
body,
headers,
}
);
return responsePromise.then(
( value ) =>
Promise.resolve( value )
.then( checkStatus )
.catch( ( response ) => parseAndThrowError( response, parse ) )
.then( ( response ) =>
parseResponseAndNormalizeError( response, parse )
),
( err ) => {
// Re-throw AbortError for the users to handle it themselves.
if ( err && err.name === 'AbortError' ) {
throw err;
}
// Otherwise, there is most likely no network connection.
// Unfortunately the message might depend on the browser.
throw {
code: 'fetch_error',
message: __( 'You are probably offline.' ),
};
}
);
};
/** @type {FetchHandler} */
let fetchHandler = defaultFetchHandler;
/**
* Defines a custom fetch handler for making the requests that will override
* the default one using window.fetch
*
* @param {FetchHandler} newFetchHandler The new fetch handler
*/
function setFetchHandler( newFetchHandler ) {
fetchHandler = newFetchHandler;
}
/**
* @template T
* @param {import('./types').APIFetchOptions} options
* @return {Promise<T>} A promise representing the request processed via the registered middlewares.
*/
function apiFetch( options ) {
// creates a nested function chain that calls all middlewares and finally the `fetchHandler`,
// converting `middlewares = [ m1, m2, m3 ]` into:
// ```
// opts1 => m1( opts1, opts2 => m2( opts2, opts3 => m3( opts3, fetchHandler ) ) );
// ```
const enhancedHandler = middlewares.reduceRight(
( /** @type {FetchHandler} */ next, middleware ) => {
return ( workingOptions ) => middleware( workingOptions, next );
},
fetchHandler
);
return enhancedHandler( options ).catch( ( error ) => {
if ( error.code !== 'rest_cookie_invalid_nonce' ) {
return Promise.reject( error );
}
// If the nonce is invalid, refresh it and try again.
return (
window
// @ts-ignore
.fetch( apiFetch.nonceEndpoint )
.then( checkStatus )
.then( ( data ) => data.text() )
.then( ( text ) => {
// @ts-ignore
apiFetch.nonceMiddleware.nonce = text;
return apiFetch( options );
} )
);
} );
}
apiFetch.use = registerMiddleware;
apiFetch.setFetchHandler = setFetchHandler;
apiFetch.createNonceMiddleware = createNonceMiddleware;
apiFetch.createPreloadingMiddleware = createPreloadingMiddleware;
apiFetch.createRootURLMiddleware = createRootURLMiddleware;
apiFetch.fetchAllMiddleware = fetchAllMiddleware;
apiFetch.mediaUploadMiddleware = mediaUploadMiddleware;
apiFetch.createThemePreviewMiddleware = createThemePreviewMiddleware;
export default apiFetch;

View File

@@ -0,0 +1,128 @@
/**
* WordPress dependencies
*/
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import apiFetch from '..';
/**
* Apply query arguments to both URL and Path, whichever is present.
*
* @param {import('../types').APIFetchOptions} props
* @param {Record<string, string | number>} queryArgs
* @return {import('../types').APIFetchOptions} The request with the modified query args
*/
const modifyQuery = ( { path, url, ...options }, queryArgs ) => ( {
...options,
url: url && addQueryArgs( url, queryArgs ),
path: path && addQueryArgs( path, queryArgs ),
} );
/**
* Duplicates parsing functionality from apiFetch.
*
* @param {Response} response
* @return {Promise<any>} Parsed response json.
*/
const parseResponse = ( response ) =>
response.json ? response.json() : Promise.reject( response );
/**
* @param {string | null} linkHeader
* @return {{ next?: string }} The parsed link header.
*/
const parseLinkHeader = ( linkHeader ) => {
if ( ! linkHeader ) {
return {};
}
const match = linkHeader.match( /<([^>]+)>; rel="next"/ );
return match
? {
next: match[ 1 ],
}
: {};
};
/**
* @param {Response} response
* @return {string | undefined} The next page URL.
*/
const getNextPageUrl = ( response ) => {
const { next } = parseLinkHeader( response.headers.get( 'link' ) );
return next;
};
/**
* @param {import('../types').APIFetchOptions} options
* @return {boolean} True if the request contains an unbounded query.
*/
const requestContainsUnboundedQuery = ( options ) => {
const pathIsUnbounded =
!! options.path && options.path.indexOf( 'per_page=-1' ) !== -1;
const urlIsUnbounded =
!! options.url && options.url.indexOf( 'per_page=-1' ) !== -1;
return pathIsUnbounded || urlIsUnbounded;
};
/**
* The REST API enforces an upper limit on the per_page option. To handle large
* collections, apiFetch consumers can pass `per_page=-1`; this middleware will
* then recursively assemble a full response array from all available pages.
*
* @type {import('../types').APIFetchMiddleware}
*/
const fetchAllMiddleware = async ( options, next ) => {
if ( options.parse === false ) {
// If a consumer has opted out of parsing, do not apply middleware.
return next( options );
}
if ( ! requestContainsUnboundedQuery( options ) ) {
// If neither url nor path is requesting all items, do not apply middleware.
return next( options );
}
// Retrieve requested page of results.
const response = await apiFetch( {
...modifyQuery( options, {
per_page: 100,
} ),
// Ensure headers are returned for page 1.
parse: false,
} );
const results = await parseResponse( response );
if ( ! Array.isArray( results ) ) {
// We have no reliable way of merging non-array results.
return results;
}
let nextPage = getNextPageUrl( response );
if ( ! nextPage ) {
// There are no further pages to request.
return results;
}
// Iteratively fetch all remaining pages until no "next" header is found.
let mergedResults = /** @type {any[]} */ ( [] ).concat( results );
while ( nextPage ) {
const nextResponse = await apiFetch( {
...options,
// Ensure the URL for the next page is used instead of any provided path.
path: undefined,
url: nextPage,
// Ensure we still get headers so we can identify the next page.
parse: false,
} );
const nextResults = await parseResponse( nextResponse );
mergedResults = mergedResults.concat( nextResults );
nextPage = getNextPageUrl( nextResponse );
}
return mergedResults;
};
export default fetchAllMiddleware;

View File

@@ -0,0 +1,43 @@
/**
* Set of HTTP methods which are eligible to be overridden.
*
* @type {Set<string>}
*/
const OVERRIDE_METHODS = new Set( [ 'PATCH', 'PUT', 'DELETE' ] );
/**
* Default request method.
*
* "A request has an associated method (a method). Unless stated otherwise it
* is `GET`."
*
* @see https://fetch.spec.whatwg.org/#requests
*
* @type {string}
*/
const DEFAULT_METHOD = 'GET';
/**
* API Fetch middleware which overrides the request method for HTTP v1
* compatibility leveraging the REST API X-HTTP-Method-Override header.
*
* @type {import('../types').APIFetchMiddleware}
*/
const httpV1Middleware = ( options, next ) => {
const { method = DEFAULT_METHOD } = options;
if ( OVERRIDE_METHODS.has( method.toUpperCase() ) ) {
options = {
...options,
headers: {
...options.headers,
'X-HTTP-Method-Override': method,
'Content-Type': 'application/json',
},
method: 'POST',
};
}
return next( options );
};
export default httpV1Middleware;

View File

@@ -0,0 +1,94 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import {
parseAndThrowError,
parseResponseAndNormalizeError,
} from '../utils/response';
/**
* @param {import('../types').APIFetchOptions} options
* @return {boolean} True if the request is for media upload.
*/
function isMediaUploadRequest( options ) {
const isCreateMethod = !! options.method && options.method === 'POST';
const isMediaEndpoint =
( !! options.path && options.path.indexOf( '/wp/v2/media' ) !== -1 ) ||
( !! options.url && options.url.indexOf( '/wp/v2/media' ) !== -1 );
return isMediaEndpoint && isCreateMethod;
}
/**
* Middleware handling media upload failures and retries.
*
* @type {import('../types').APIFetchMiddleware}
*/
const mediaUploadMiddleware = ( options, next ) => {
if ( ! isMediaUploadRequest( options ) ) {
return next( options );
}
let retries = 0;
const maxRetries = 5;
/**
* @param {string} attachmentId
* @return {Promise<any>} Processed post response.
*/
const postProcess = ( attachmentId ) => {
retries++;
return next( {
path: `/wp/v2/media/${ attachmentId }/post-process`,
method: 'POST',
data: { action: 'create-image-subsizes' },
parse: false,
} ).catch( () => {
if ( retries < maxRetries ) {
return postProcess( attachmentId );
}
next( {
path: `/wp/v2/media/${ attachmentId }?force=true`,
method: 'DELETE',
} );
return Promise.reject();
} );
};
return next( { ...options, parse: false } )
.catch( ( response ) => {
const attachmentId = response.headers.get(
'x-wp-upload-attachment-id'
);
if (
response.status >= 500 &&
response.status < 600 &&
attachmentId
) {
return postProcess( attachmentId ).catch( () => {
if ( options.parse !== false ) {
return Promise.reject( {
code: 'post_process',
message: __(
'Media upload failed. If this is a photo or a large image, please scale it down and try again.'
),
} );
}
return Promise.reject( response );
} );
}
return parseAndThrowError( response, options.parse );
} )
.then( ( response ) =>
parseResponseAndNormalizeError( response, options.parse )
);
};
export default mediaUploadMiddleware;

View File

@@ -0,0 +1,30 @@
/**
* @type {import('../types').APIFetchMiddleware}
*/
const namespaceAndEndpointMiddleware = ( options, next ) => {
let path = options.path;
let namespaceTrimmed, endpointTrimmed;
if (
typeof options.namespace === 'string' &&
typeof options.endpoint === 'string'
) {
namespaceTrimmed = options.namespace.replace( /^\/|\/$/g, '' );
endpointTrimmed = options.endpoint.replace( /^\//, '' );
if ( endpointTrimmed ) {
path = namespaceTrimmed + '/' + endpointTrimmed;
} else {
path = namespaceTrimmed;
}
}
delete options.namespace;
delete options.endpoint;
return next( {
...options,
path,
} );
};
export default namespaceAndEndpointMiddleware;

View File

@@ -0,0 +1,37 @@
/**
* @param {string} nonce
* @return {import('../types').APIFetchMiddleware & { nonce: string }} A middleware to enhance a request with a nonce.
*/
function createNonceMiddleware( nonce ) {
/**
* @type {import('../types').APIFetchMiddleware & { nonce: string }}
*/
const middleware = ( options, next ) => {
const { headers = {} } = options;
// If an 'X-WP-Nonce' header (or any case-insensitive variation
// thereof) was specified, no need to add a nonce header.
for ( const headerName in headers ) {
if (
headerName.toLowerCase() === 'x-wp-nonce' &&
headers[ headerName ] === middleware.nonce
) {
return next( options );
}
}
return next( {
...options,
headers: {
...headers,
'X-WP-Nonce': middleware.nonce,
},
} );
};
middleware.nonce = nonce;
return middleware;
}
export default createNonceMiddleware;

View File

@@ -0,0 +1,82 @@
/**
* WordPress dependencies
*/
import { addQueryArgs, getQueryArgs, normalizePath } from '@wordpress/url';
/**
* @param {Record<string, any>} preloadedData
* @return {import('../types').APIFetchMiddleware} Preloading middleware.
*/
function createPreloadingMiddleware( preloadedData ) {
const cache = Object.fromEntries(
Object.entries( preloadedData ).map( ( [ path, data ] ) => [
normalizePath( path ),
data,
] )
);
return ( options, next ) => {
const { parse = true } = options;
/** @type {string | void} */
let rawPath = options.path;
if ( ! rawPath && options.url ) {
const { rest_route: pathFromQuery, ...queryArgs } = getQueryArgs(
options.url
);
if ( typeof pathFromQuery === 'string' ) {
rawPath = addQueryArgs( pathFromQuery, queryArgs );
}
}
if ( typeof rawPath !== 'string' ) {
return next( options );
}
const method = options.method || 'GET';
const path = normalizePath( rawPath );
if ( 'GET' === method && cache[ path ] ) {
const cacheData = cache[ path ];
// Unsetting the cache key ensures that the data is only used a single time.
delete cache[ path ];
return prepareResponse( cacheData, !! parse );
} else if (
'OPTIONS' === method &&
cache[ method ] &&
cache[ method ][ path ]
) {
const cacheData = cache[ method ][ path ];
// Unsetting the cache key ensures that the data is only used a single time.
delete cache[ method ][ path ];
return prepareResponse( cacheData, !! parse );
}
return next( options );
};
}
/**
* This is a helper function that sends a success response.
*
* @param {Record<string, any>} responseData
* @param {boolean} parse
* @return {Promise<any>} Promise with the response.
*/
function prepareResponse( responseData, parse ) {
return Promise.resolve(
parse
? responseData.body
: new window.Response( JSON.stringify( responseData.body ), {
status: 200,
statusText: 'OK',
headers: responseData.headers,
} )
);
}
export default createPreloadingMiddleware;

View File

@@ -0,0 +1,44 @@
/**
* Internal dependencies
*/
import namespaceAndEndpointMiddleware from './namespace-endpoint';
/**
* @param {string} rootURL
* @return {import('../types').APIFetchMiddleware} Root URL middleware.
*/
const createRootURLMiddleware = ( rootURL ) => ( options, next ) => {
return namespaceAndEndpointMiddleware( options, ( optionsWithPath ) => {
let url = optionsWithPath.url;
let path = optionsWithPath.path;
let apiRoot;
if ( typeof path === 'string' ) {
apiRoot = rootURL;
if ( -1 !== rootURL.indexOf( '?' ) ) {
path = path.replace( '?', '&' );
}
path = path.replace( /^\//, '' );
// API root may already include query parameter prefix if site is
// configured to use plain permalinks.
if (
'string' === typeof apiRoot &&
-1 !== apiRoot.indexOf( '?' )
) {
path = path.replace( '?', '&' );
}
url = apiRoot + path;
}
return next( {
...optionsWithPath,
url,
} );
} );
};
export default createRootURLMiddleware;

View File

@@ -0,0 +1,54 @@
describe( 'Fetch All Middleware', () => {
beforeEach( jest.resetModules );
it( 'should defer with the same options to the next middleware', async () => {
expect.hasAssertions();
const originalOptions = { path: '/posts' };
const next = ( options ) => {
expect( options ).toBe( originalOptions );
return Promise.resolve( 'ok' );
};
await require( '../fetch-all-middleware' ).default(
originalOptions,
next
);
} );
it( 'should paginate the request', async () => {
expect.hasAssertions();
const originalOptions = { url: '/posts?per_page=-1' };
let counter = 1;
jest.doMock( '../../index.js', () => ( options ) => {
const expectedUrl =
counter === 1
? '/posts?per_page=100'
: '/posts?per_page=100&page=2';
expect( options.url ).toBe( expectedUrl );
const response = Promise.resolve( {
status: 200,
headers: {
get() {
return options.url === '/posts?per_page=100'
? '</posts?per_page=100&page=2>; rel="next"'
: '';
},
},
json() {
return Promise.resolve( [ 'item' ] );
},
} );
counter++;
return response;
} );
const result = await require( '../fetch-all-middleware' ).default(
originalOptions,
() => {}
);
expect( result ).toEqual( [ 'item', 'item' ] );
} );
} );

View File

@@ -0,0 +1,39 @@
/**
* Internal dependencies
*/
import httpV1Middleware from '../http-v1';
describe( 'HTTP v1 Middleware', () => {
it( 'should use a POST for a PUT requests', () => {
expect.hasAssertions();
const callback = ( options ) => {
expect( options.method ).toBe( 'POST' );
expect( options.headers[ 'X-HTTP-Method-Override' ] ).toBe( 'PUT' );
};
httpV1Middleware( { method: 'PUT', data: {} }, callback );
} );
it( "shouldn't touch the options for GET requests", () => {
expect.hasAssertions();
const requestOptions = { method: 'GET', path: '/wp/v2/posts' };
const callback = ( options ) => {
expect( options ).toBe( requestOptions );
};
httpV1Middleware( requestOptions, callback );
} );
it( "shouldn't touch the options for an undefined method", () => {
expect.hasAssertions();
const requestOptions = { path: '/wp/v2/posts' };
const callback = ( options ) => {
expect( options ).toBe( requestOptions );
};
httpV1Middleware( requestOptions, callback );
} );
} );

View File

@@ -0,0 +1,35 @@
/**
* Internal dependencies
*/
import mediaUploadMiddleware from '../media-upload';
describe( 'Media Upload Middleware', () => {
it( 'should defer to the next middleware with the same options', () => {
expect.hasAssertions();
const originalOptions = { path: '/wp/v2/media' };
const next = ( options ) => {
expect( options ).toBe( originalOptions );
};
mediaUploadMiddleware( originalOptions, next );
} );
it( 'should change options not to parse', () => {
expect.hasAssertions();
const requestOptions = { method: 'POST', path: '/wp/v2/media' };
const next = ( options ) => {
expect( options.parse ).toBe( false );
return Promise.resolve( {
status: 200,
json() {
return Promise.resolve( [ 'item' ] );
},
} );
};
mediaUploadMiddleware( requestOptions, next );
} );
} );

View File

@@ -0,0 +1,23 @@
/**
* Internal dependencies
*/
import namespaceEndpointMiddleware from '../namespace-endpoint';
describe( 'Namespace & Endpoint middleware', () => {
it( 'should concat the endpoint and namespace into a path property', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
namespace: '/wp/v2',
endpoint: '/posts',
};
const callback = ( options ) => {
expect( options.path ).toBe( 'wp/v2/posts' );
expect( options.namespace ).toBeUndefined();
expect( options.endpoint ).toBeUndefined();
};
namespaceEndpointMiddleware( requestOptions, callback );
} );
} );

View File

@@ -0,0 +1,40 @@
/**
* Internal dependencies
*/
import createNonceMiddleware from '../nonce';
describe( 'Nonce middleware', () => {
it( 'should add a nonce header to the request', () => {
expect.hasAssertions();
const nonce = 'nonce';
const nonceMiddleware = createNonceMiddleware( nonce );
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts',
};
const callback = ( options ) => {
expect( options.headers[ 'X-WP-Nonce' ] ).toBe( nonce );
};
nonceMiddleware( requestOptions, callback );
} );
it( 'should update the nonce in requests with outdated nonces', () => {
expect.hasAssertions();
const nonce = 'new nonce';
const nonceMiddleware = createNonceMiddleware( nonce );
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts',
headers: { 'X-WP-Nonce': 'existing nonce' },
};
const callback = ( options ) => {
expect( options.headers[ 'X-WP-Nonce' ] ).toBe( 'new nonce' );
};
nonceMiddleware( requestOptions, callback );
} );
} );

View File

@@ -0,0 +1,291 @@
/**
* Internal dependencies
*/
import createPreloadingMiddleware from '../preloading';
describe( 'Preloading Middleware', () => {
describe( 'given preloaded data', () => {
describe( 'when data is requested from a preloaded endpoint', () => {
describe( 'and it is requested for the first time', () => {
it( 'should return the preloaded data', () => {
const body = {
status: 'this is the preloaded response',
};
const preloadedData = {
'wp/v2/posts': {
body,
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'GET',
path: 'wp/v2/posts',
};
const response = preloadingMiddleware( requestOptions );
return response.then( ( value ) => {
expect( value ).toEqual( body );
} );
} );
} );
describe( 'and it has already been requested', () => {
it( 'should not return the preloaded data', () => {
const body = {
status: 'this is the preloaded response',
};
const preloadedData = {
'wp/v2/posts': {
body,
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'GET',
path: 'wp/v2/posts',
};
const nextSpy = jest.fn();
preloadingMiddleware( requestOptions, nextSpy );
expect( nextSpy ).not.toHaveBeenCalled();
preloadingMiddleware( requestOptions, nextSpy );
expect( nextSpy ).toHaveBeenCalled();
} );
} );
describe( 'and the OPTIONS request has a parse flag', () => {
it( 'should return the full response if parse: false', () => {
const noResponseMock =
'undefined' === typeof window.Response;
if ( noResponseMock ) {
window.Response = class {
constructor( body, options ) {
this.body = JSON.parse( body );
this.headers = options.headers;
}
};
}
const data = {
body: {
status: 'this is the preloaded response',
},
headers: {
Allow: 'GET, POST',
},
};
const preloadedData = {
OPTIONS: {
'wp/v2/posts': data,
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'OPTIONS',
path: 'wp/v2/posts',
parse: false,
};
const response = preloadingMiddleware( requestOptions );
if ( noResponseMock ) {
delete window.Response;
}
return response.then( ( value ) => {
expect( value ).toEqual( data );
} );
} );
it( 'should return only the response body if parse: true', () => {
const body = {
status: 'this is the preloaded response',
};
const preloadedData = {
OPTIONS: {
'wp/v2/posts': {
body,
headers: {
Allow: 'GET, POST',
},
},
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'OPTIONS',
path: 'wp/v2/posts',
parse: true,
};
const response = preloadingMiddleware( requestOptions );
return response.then( ( value ) => {
expect( value ).toEqual( body );
} );
} );
} );
} );
describe( 'when the requested data is not from a preloaded endpoint', () => {
it( 'should not return preloaded data', () => {
const body = {
status: 'this is the preloaded response',
};
const preloadedData = {
'wp/v2/posts': {
body,
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'GET',
path: 'wp/v2/fake_resource',
};
const nextSpy = jest.fn();
preloadingMiddleware( requestOptions, nextSpy );
expect( nextSpy ).toHaveBeenCalled();
} );
} );
} );
it( 'should normalize on stable path', async () => {
const body = { content: 'example' };
const preloadedData = {
'wp/v2/demo-reverse-alphabetical?foo=bar&baz=quux': { body },
'wp/v2/demo-alphabetical?baz=quux&foo=bar': { body },
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
let requestOptions = {
method: 'GET',
path: 'wp/v2/demo-reverse-alphabetical?baz=quux&foo=bar',
};
let value = await preloadingMiddleware( requestOptions, () => {} );
expect( value ).toEqual( body );
requestOptions = {
method: 'GET',
path: 'wp/v2/demo-alphabetical?foo=bar&baz=quux',
};
value = await preloadingMiddleware( requestOptions, () => {} );
expect( value ).toEqual( body );
} );
it( 'should recognize an urlencoded query param', async () => {
const body = { foo: 'foo', bar: 'bar' };
const preloadingMiddleware = createPreloadingMiddleware( {
'/?_fields=foo,bar': { body },
} );
const response = await preloadingMiddleware(
{
method: 'GET',
path: '/?_fields=foo%2Cbar',
},
() => {}
);
expect( response ).toEqual( body );
} );
it( 'should recognize rest_route query param as path', async () => {
const body = { foo: 'foo' };
const preloadingMiddleware = createPreloadingMiddleware( {
'/': { body },
} );
const response = await preloadingMiddleware(
{
method: 'GET',
url: '/index.php?rest_route=%2F',
},
() => {}
);
expect( response ).toEqual( body );
} );
it( 'should recognize additional query params after rest_route', async () => {
const body = { foo: 'foo', bar: 'bar' };
const preloadingMiddleware = createPreloadingMiddleware( {
'/?_fields=foo,bar': { body },
} );
const response = await preloadingMiddleware(
{
method: 'GET',
url: '/index.php?rest_route=%2F&_fields=foo%2Cbar',
},
() => {}
);
expect( response ).toEqual( body );
} );
it( 'should remove OPTIONS type requests from the cache after the first hit', async () => {
const body = { content: 'example' };
const preloadedData = {
OPTIONS: {
'wp/v2/demo': { body },
},
};
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method: 'OPTIONS',
path: 'wp/v2/demo',
};
const firstMiddleware = jest.fn();
preloadingMiddleware( requestOptions, firstMiddleware );
expect( firstMiddleware ).not.toHaveBeenCalled();
await preloadingMiddleware( requestOptions, firstMiddleware );
const secondMiddleware = jest.fn();
await preloadingMiddleware( requestOptions, secondMiddleware );
expect( secondMiddleware ).toHaveBeenCalledTimes( 1 );
} );
describe.each( [ [ 'GET' ], [ 'OPTIONS' ] ] )( '%s', ( method ) => {
describe.each( [
[ 'all empty', {} ],
[ 'method empty', { [ method ]: {} } ],
] )( '%s', ( label, preloadedData ) => {
it( 'should move to the next middleware if no preloaded data', () => {
const preloadingMiddleware =
createPreloadingMiddleware( preloadedData );
const requestOptions = {
method,
path: 'wp/v2/posts',
};
const callback = ( options ) => {
expect( options ).toBe( requestOptions );
return true;
};
const ret = preloadingMiddleware( requestOptions, callback );
expect( ret ).toBe( true );
} );
} );
} );
} );

View File

@@ -0,0 +1,24 @@
/**
* Internal dependencies
*/
import createRootUrlMiddleware from '../root-url';
describe( 'Root URL middleware', () => {
it( 'should append the root URL', () => {
expect.hasAssertions();
const rootURL = 'http://wp.org/wp-admin/rest/';
const rootURLMiddleware = createRootUrlMiddleware( rootURL );
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts',
};
const callback = ( options ) => {
expect( options.url ).toBe(
'http://wp.org/wp-admin/rest/wp/v2/posts'
);
};
rootURLMiddleware( requestOptions, callback );
} );
} );

View File

@@ -0,0 +1,134 @@
/**
* Internal dependencies
*/
import userLocaleMiddleware from '../user-locale';
describe( 'User locale middleware', () => {
it( 'should append the _locale parameter to the path', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts',
};
const callback = ( options ) => {
expect( options.path ).toBe( '/wp/v2/posts?_locale=user' );
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'should append the _locale parameter to path with existing query argument', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts?foo=bar',
};
const callback = ( options ) => {
expect( options.path ).toBe( '/wp/v2/posts?foo=bar&_locale=user' );
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'does not modify existing single _locale parameter in path', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts?_locale=foo',
};
const callback = ( options ) => {
expect( options.path ).toBe( '/wp/v2/posts?_locale=foo' );
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'does not modify existing _locale parameter in path', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
path: '/wp/v2/posts?foo=bar&_locale=foo',
};
const callback = ( options ) => {
expect( options.path ).toBe( '/wp/v2/posts?foo=bar&_locale=foo' );
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'should append the _locale parameter to the url', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
url: 'http://wp.org/wp-json/wp/v2/posts',
};
const callback = ( options ) => {
expect( options.url ).toBe(
'http://wp.org/wp-json/wp/v2/posts?_locale=user'
);
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'should append the _locale parameter to url with existing query argument', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
url: 'http://wp.org/wp-json/wp/v2/posts?foo=bar',
};
const callback = ( options ) => {
expect( options.url ).toBe(
'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=user'
);
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'does not modify existing single _locale parameter in url', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
url: 'http://wp.org/wp-json/wp/v2/posts?_locale=foo',
};
const callback = ( options ) => {
expect( options.url ).toBe(
'http://wp.org/wp-json/wp/v2/posts?_locale=foo'
);
};
userLocaleMiddleware( requestOptions, callback );
} );
it( 'does not modify existing _locale parameter in url', () => {
expect.hasAssertions();
const requestOptions = {
method: 'GET',
url: 'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=foo',
};
const callback = ( options ) => {
expect( options.url ).toBe(
'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=foo'
);
};
userLocaleMiddleware( requestOptions, callback );
} );
} );

View File

@@ -0,0 +1,42 @@
/**
* WordPress dependencies
*/
import { addQueryArgs, getQueryArg, removeQueryArgs } from '@wordpress/url';
/**
* This appends a `wp_theme_preview` parameter to the REST API request URL if
* the admin URL contains a `theme` GET parameter.
*
* If the REST API request URL has contained the `wp_theme_preview` parameter as `''`,
* then bypass this middleware.
*
* @param {Record<string, any>} themePath
* @return {import('../types').APIFetchMiddleware} Preloading middleware.
*/
const createThemePreviewMiddleware = ( themePath ) => ( options, next ) => {
if ( typeof options.url === 'string' ) {
const wpThemePreview = getQueryArg( options.url, 'wp_theme_preview' );
if ( wpThemePreview === undefined ) {
options.url = addQueryArgs( options.url, {
wp_theme_preview: themePath,
} );
} else if ( wpThemePreview === '' ) {
options.url = removeQueryArgs( options.url, 'wp_theme_preview' );
}
}
if ( typeof options.path === 'string' ) {
const wpThemePreview = getQueryArg( options.path, 'wp_theme_preview' );
if ( wpThemePreview === undefined ) {
options.path = addQueryArgs( options.path, {
wp_theme_preview: themePath,
} );
} else if ( wpThemePreview === '' ) {
options.path = removeQueryArgs( options.path, 'wp_theme_preview' );
}
}
return next( options );
};
export default createThemePreviewMiddleware;

View File

@@ -0,0 +1,27 @@
/**
* WordPress dependencies
*/
import { addQueryArgs, hasQueryArg } from '@wordpress/url';
/**
* @type {import('../types').APIFetchMiddleware}
*/
const userLocaleMiddleware = ( options, next ) => {
if (
typeof options.url === 'string' &&
! hasQueryArg( options.url, '_locale' )
) {
options.url = addQueryArgs( options.url, { _locale: 'user' } );
}
if (
typeof options.path === 'string' &&
! hasQueryArg( options.path, '_locale' )
) {
options.path = addQueryArgs( options.path, { _locale: 'user' } );
}
return next( options );
};
export default userLocaleMiddleware;

291
node_modules/@wordpress/api-fetch/src/test/index.js generated vendored Normal file
View File

@@ -0,0 +1,291 @@
/**
* Internal dependencies
*/
import apiFetch from '../';
/**
* Mock return value for a successful fetch JSON return value.
*
* @return {Promise} Mock return value.
*/
const DEFAULT_FETCH_MOCK_RETURN = Promise.resolve( {
status: 200,
json: () => Promise.resolve( {} ),
} );
describe( 'apiFetch', () => {
const originalFetch = window.fetch;
beforeEach( () => {
window.fetch = jest.fn();
} );
afterAll( () => {
window.fetch = originalFetch;
} );
it( 'should call the API properly', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 200,
json() {
return Promise.resolve( { message: 'ok' } );
},
} )
);
return apiFetch( { path: '/random' } ).then( ( body ) => {
expect( body ).toEqual( { message: 'ok' } );
} );
} );
it( 'should fetch with non-JSON body', () => {
window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN );
const body = 'FormData';
apiFetch( {
path: '/wp/v2/media',
method: 'POST',
body,
} );
expect( window.fetch ).toHaveBeenCalledWith(
'/wp/v2/media?_locale=user',
{
credentials: 'include',
headers: {
Accept: 'application/json, */*;q=0.1',
},
method: 'POST',
body,
}
);
} );
it( 'should fetch with a JSON body', () => {
window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN );
apiFetch( {
path: '/wp/v2/posts',
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
data: {},
} );
expect( window.fetch ).toHaveBeenCalledWith(
'/wp/v2/posts?_locale=user',
{
body: '{}',
credentials: 'include',
headers: {
Accept: 'application/json, */*;q=0.1',
'Content-Type': 'application/json',
},
method: 'POST',
}
);
} );
it( 'should respect developer-provided options', () => {
window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN );
apiFetch( {
path: '/wp/v2/posts',
method: 'POST',
data: {},
credentials: 'omit',
} );
expect( window.fetch ).toHaveBeenCalledWith(
'/wp/v2/posts?_locale=user',
{
body: '{}',
credentials: 'omit',
headers: {
Accept: 'application/json, */*;q=0.1',
'Content-Type': 'application/json',
},
method: 'POST',
}
);
} );
it( 'should return the error message properly', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 400,
json() {
return Promise.resolve( {
code: 'bad_request',
message: 'Bad Request',
} );
},
} )
);
return apiFetch( { path: '/random' } ).catch( ( body ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( body ).toEqual( {
code: 'bad_request',
message: 'Bad Request',
} );
} );
} );
it( 'should return invalid JSON error if no json response', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 200,
} )
);
return apiFetch( { path: '/random' } ).catch( ( body ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( body ).toEqual( {
code: 'invalid_json',
message: 'The response is not a valid JSON response.',
} );
} );
} );
it( 'should return invalid JSON error if response is not valid', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 200,
json() {
return Promise.reject();
},
} )
);
return apiFetch( { path: '/random' } ).catch( ( body ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( body ).toEqual( {
code: 'invalid_json',
message: 'The response is not a valid JSON response.',
} );
} );
} );
it( 'should return offline error when fetch errors', () => {
window.fetch.mockReturnValue( Promise.reject() );
return apiFetch( { path: '/random' } ).catch( ( body ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( body ).toEqual( {
code: 'fetch_error',
message: 'You are probably offline.',
} );
} );
} );
it( 'should throw AbortError when fetch aborts', async () => {
const abortError = new Error();
abortError.name = 'AbortError';
abortError.code = 20;
window.fetch.mockReturnValue( Promise.reject( abortError ) );
const controller = new window.AbortController();
const promise = apiFetch( {
path: '/random',
signal: controller.signal,
} );
controller.abort();
let error;
try {
await promise;
} catch ( err ) {
error = err;
}
expect( error.name ).toBe( 'AbortError' );
} );
it( 'should return null if response has no content status code', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 204,
} )
);
return apiFetch( { path: '/random' } ).catch( ( body ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( body ).toEqual( null );
} );
} );
it( 'should not try to parse the response', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 200,
} )
);
return apiFetch( { path: '/random', parse: false } ).then(
( response ) => {
expect( response ).toEqual( {
status: 200,
} );
}
);
} );
it( 'should not try to parse the error', () => {
window.fetch.mockReturnValue(
Promise.resolve( {
status: 400,
} )
);
return apiFetch( { path: '/random', parse: false } ).catch(
( response ) => {
// eslint-disable-next-line jest/no-conditional-expect
expect( response ).toEqual( {
status: 400,
} );
}
);
} );
it( 'should not use the default fetch handler when using a custom fetch handler', () => {
const customFetchHandler = jest.fn();
apiFetch.setFetchHandler( customFetchHandler );
apiFetch( { path: '/random' } );
expect( window.fetch ).not.toHaveBeenCalled();
expect( customFetchHandler ).toHaveBeenCalledWith( {
path: '/random?_locale=user',
} );
} );
it( 'should run the last-registered user-defined middleware first', () => {
// This could potentially impact other tests in that a lingering
// middleware is left. For the purposes of this test, it is sufficient
// to ensure that the last-registered middleware receives the original
// options object. It also assumes that some built-in middleware would
// either mutate or clone the original options if the extra middleware
// had been pushed to the stack.
expect.assertions( 1 );
const expectedOptions = {};
apiFetch.use( ( actualOptions, next ) => {
expect( actualOptions ).toBe( expectedOptions );
return next( actualOptions );
} );
apiFetch( expectedOptions );
} );
} );

18
node_modules/@wordpress/api-fetch/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,18 @@
export interface APIFetchOptions extends RequestInit {
// Override headers, we only accept it as an object due to the `nonce` middleware
headers?: Record< string, string >;
path?: string;
url?: string;
/**
* @default true
*/
parse?: boolean;
data?: any;
namespace?: string;
endpoint?: string;
}
export type APIFetchMiddleware = (
options: APIFetchOptions,
next: ( nextOptions: APIFetchOptions ) => Promise< any >
) => Promise< any >;

View File

@@ -0,0 +1,85 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Parses the apiFetch response.
*
* @param {Response} response
* @param {boolean} shouldParseResponse
*
* @return {Promise<any> | null | Response} Parsed response.
*/
const parseResponse = ( response, shouldParseResponse = true ) => {
if ( shouldParseResponse ) {
if ( response.status === 204 ) {
return null;
}
return response.json ? response.json() : Promise.reject( response );
}
return response;
};
/**
* Calls the `json` function on the Response, throwing an error if the response
* doesn't have a json function or if parsing the json itself fails.
*
* @param {Response} response
* @return {Promise<any>} Parsed response.
*/
const parseJsonAndNormalizeError = ( response ) => {
const invalidJsonError = {
code: 'invalid_json',
message: __( 'The response is not a valid JSON response.' ),
};
if ( ! response || ! response.json ) {
throw invalidJsonError;
}
return response.json().catch( () => {
throw invalidJsonError;
} );
};
/**
* Parses the apiFetch response properly and normalize response errors.
*
* @param {Response} response
* @param {boolean} shouldParseResponse
*
* @return {Promise<any>} Parsed response.
*/
export const parseResponseAndNormalizeError = (
response,
shouldParseResponse = true
) => {
return Promise.resolve(
parseResponse( response, shouldParseResponse )
).catch( ( res ) => parseAndThrowError( res, shouldParseResponse ) );
};
/**
* Parses a response, throwing an error if parsing the response fails.
*
* @param {Response} response
* @param {boolean} shouldParseResponse
* @return {Promise<any>} Parsed response.
*/
export function parseAndThrowError( response, shouldParseResponse = true ) {
if ( ! shouldParseResponse ) {
throw response;
}
return parseJsonAndNormalizeError( response ).then( ( error ) => {
const unknownError = {
code: 'unknown_error',
message: __( 'An unknown error occurred.' ),
};
throw error || unknownError;
} );
}