From d36d18cc80e5add47b2eb7b33c62f9716a675c27 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 23 Jun 2026 14:04:17 +0200 Subject: [PATCH 01/11] Abstract the mechanics of the indexation process Added the getAlreadyIndexedStateName function because the Tools page and the FTC use a different string. All the rest is 100% untouched --- .../js/src/components/AbstractIndexation.js | 308 ++++++++++++++++++ packages/js/src/components/Indexation.js | 270 +-------------- .../steps/indexation/indexation.js | 267 +-------------- 3 files changed, 321 insertions(+), 524 deletions(-) create mode 100644 packages/js/src/components/AbstractIndexation.js diff --git a/packages/js/src/components/AbstractIndexation.js b/packages/js/src/components/AbstractIndexation.js new file mode 100644 index 00000000000..ac191fbd415 --- /dev/null +++ b/packages/js/src/components/AbstractIndexation.js @@ -0,0 +1,308 @@ +/* global yoastIndexingData */ +import { Component, flushSync } from "@wordpress/element"; +import PropTypes from "prop-types"; +import { addHistoryState, removeSearchParam } from "../helpers/urlHelpers"; +import RequestError from "../errors/RequestError"; +import ParseError from "../errors/ParseError"; + +export const STATE = { + /** + * When the process has not started yet, or has been stopped manually. + */ + IDLE: "idle", + /** + * When the indexing process is in progress. + */ + IN_PROGRESS: "in_progress", + /** + * When an error has occurred during the indexing process that has stopped the process. + */ + ERRORED: "errored", + /** + * When the indexing process has finished. + */ + COMPLETED: "completed", +}; + +/* eslint-disable react/no-unused-state -- `error`/`firstTime` are set here but read by subclass render methods, unseen by this rule across files. */ + +/** + * Drives the indexing process: calls each indexing endpoint in turn and tracks progress. + * + * This base class holds the full request/cursor engine and state machine shared by every + * Indexation UI. Subclasses supply only the presentation by implementing `render()` (and the + * `render*` helpers it calls); they may override `getAlreadyIndexedStateName()` to change the + * state signalled when there is nothing left to index on mount. + */ +class AbstractIndexation extends Component { + /** + * Indexing constructor. + * + * @param {Object} props The properties. + */ + constructor( props ) { + super( props ); + + this.settings = yoastIndexingData; + + this.state = { + state: STATE.IDLE, + processed: 0, + error: null, + amount: parseInt( this.settings.amount, 10 ), + firstTime: ( + this.settings.firstTime === "1" + ), + }; + + this.startIndexing = this.startIndexing.bind( this ); + this.stopIndexing = this.stopIndexing.bind( this ); + } + + /** + * The state name signalled to `indexingStateCallback` when there is nothing left to index on mount. + * + * @returns {string} The state name. + */ + getAlreadyIndexedStateName() { + return "completed"; + } + + /** + * Does an indexing request. + * + * @param {string} url The url of the indexing that should be done. + * @param {string} nonce The WordPress nonce value for in the header. + * + * @returns {Promise} The request promise. + */ + async doIndexingRequest( url, nonce ) { + const response = await fetch( url, { + method: "POST", + headers: { + "X-WP-Nonce": nonce, + }, + } ); + + const responseText = await response.text(); + + let data; + try { + /* + * Sometimes, in case of a fatal error, or if WP_DEBUG is on and a DB query fails, + * non-JSON is dumped into the HTTP response body, so account for that here. + */ + data = JSON.parse( responseText ); + } catch ( error ) { + throw new ParseError( "Error parsing the response to JSON.", responseText ); + } + + // Throw an error when the response's status code is not in the 200-299 range. + if ( ! response.ok ) { + const stackTrace = data.data ? data.data.stackTrace : ""; + throw new RequestError( data.message, url, "POST", response.status, stackTrace ); + } + + return data; + } + + /** + * Does any registered indexing action *before* a call to an index endpoint. + * + * @param {string} endpoint The endpoint that has been called. + * + * @returns {Promise} An empty promise. + */ + async doPreIndexingAction( endpoint ) { + if ( typeof this.props.preIndexingActions[ endpoint ] === "function" ) { + await this.props.preIndexingActions[ endpoint ]( this.settings ); + } + } + + /** + * Does any registered indexing action *after* a call to an index endpoint. + * + * @param {string} endpoint The endpoint that has been called. + * @param {Object} response The response of the call to the endpoint. + * + * @returns {Promise} An empty promise. + */ + async doPostIndexingAction( endpoint, response ) { + if ( typeof this.props.indexingActions[ endpoint ] === "function" ) { + await this.props.indexingActions[ endpoint ]( response.objects, this.settings ); + } + } + + /** + * Does the indexing of a given endpoint. + * + * @param {string} endpoint The endpoint. + * + * @returns {Promise} The indexing promise. + */ + async doIndexing( endpoint ) { + let url = this.settings.restApi.root + this.settings.restApi.indexing_endpoints[ endpoint ]; + + while ( this.isState( STATE.IN_PROGRESS ) && url !== false ) { + try { + await this.doPreIndexingAction( endpoint ); + const response = await this.doIndexingRequest( url, this.settings.restApi.nonce ); + await this.doPostIndexingAction( endpoint, response ); + + flushSync( () => { + this.setState( previousState => ( + { + processed: previousState.processed + response.objects.length, + firstTime: false, + } + ) ); + } ); + + url = response.next_url; + } catch ( error ) { + flushSync( () => { + this.setState( { + state: STATE.ERRORED, + error: error, + firstTime: false, + } ); + } ); + } + } + } + + /** + * Indexes the objects by calling each indexing endpoint in turn. + * + * @returns {Promise} The indexing promise. + */ + async index() { + for ( const endpoint of Object.keys( this.settings.restApi.indexing_endpoints ) ) { + await this.doIndexing( endpoint ); + } + /* + * Set the indexing process as completed only when there is no error + * and the user has not stopped the process manually. + */ + if ( ! this.isState( STATE.ERRORED ) && ! this.isState( STATE.IDLE ) ) { + this.completeIndexing(); + } + } + + /** + * Starts the indexing process. + * + * @returns {Promise} The start indexing promise. + */ + async startIndexing() { + /* + * Since `setState` is asynchronous in nature, we have to supply a callback + * to make sure the state is correctly set before trying to call the first + * endpoint. + */ + this.setState( { processed: 0, state: STATE.IN_PROGRESS }, this.index ); + } + + /** + * Sets the state of the indexing process to completed. + * + * @returns {void} + */ + completeIndexing() { + this.setState( { state: STATE.COMPLETED } ); + } + + /** + * Stops the indexing process. + * + * @returns {void} + */ + stopIndexing() { + this.setState( previousState => ( + { + state: STATE.IDLE, + processed: 0, + amount: previousState.amount - previousState.processed, + } + ) ); + } + + /** + * Start indexation on mount, when redirected from the "Start SEO data optimization" button in the dashboard notification. + * + * @returns {void} + */ + componentDidMount() { + if ( this.settings.disabled ) { + return; + } + + this.props.indexingStateCallback( this.state.amount === 0 ? this.getAlreadyIndexedStateName() : this.state.state ); + + const shouldStart = new URLSearchParams( window.location.search ).get( "start-indexation" ) === "true"; + + if ( shouldStart ) { + const currentURL = removeSearchParam( window.location.href, "start-indexation" ); + addHistoryState( null, document.title, currentURL ); + + this.startIndexing(); + } + } + + /** + * Signals state changes to an optional callback function. + * + * @param {Object} _prevProps The previous props, unused in the current implementation. + * @param {Object} prevState The previous state. + * + * @returns {void} + */ + componentDidUpdate( _prevProps, prevState ) { + if ( this.state.state !== prevState.state ) { + this.props.indexingStateCallback( this.state.state ); + } + } + + /** + * If the current state of the indexing process is the given state. + * + * @param {STATE.IDLE|STATE.ERRORED|STATE.IN_PROGRESS|STATE.COMPLETED} state The state value to check against. + * + * @returns {boolean} If the current state of the indexing process is the given state. + */ + isState( state ) { + return this.state.state === state; + } +} + +/* eslint-enable react/no-unused-state */ + +/** + * The prop types shared by every Indexation component. + * + * Exported as a plain object (rather than read back off `AbstractIndexation.propTypes`) so + * subclasses can extend it without tripping `react/forbid-foreign-prop-types`. + * + * @type {Object} + */ +export const indexationPropTypes = { + indexingActions: PropTypes.object, + preIndexingActions: PropTypes.object, + indexingStateCallback: PropTypes.func, +}; + +/** + * The default props shared by every Indexation component. + * + * @type {Object} + */ +export const indexationDefaultProps = { + indexingActions: {}, + preIndexingActions: {}, + indexingStateCallback: () => {}, +}; + +AbstractIndexation.propTypes = indexationPropTypes; +AbstractIndexation.defaultProps = indexationDefaultProps; + +export default AbstractIndexation; diff --git a/packages/js/src/components/Indexation.js b/packages/js/src/components/Indexation.js index 830a97d73e7..2ec13f6a05e 100644 --- a/packages/js/src/components/Indexation.js +++ b/packages/js/src/components/Indexation.js @@ -1,267 +1,15 @@ /* global yoastIndexingData */ -import { Component, flushSync, Fragment } from "@wordpress/element"; +import { Fragment } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; import { Alert, NewButton, ProgressBar } from "@yoast/components"; import { colors } from "@yoast/style-guide"; -import PropTypes from "prop-types"; -import { addHistoryState, removeSearchParam } from "../helpers/urlHelpers"; +import AbstractIndexation, { STATE } from "./AbstractIndexation"; import IndexingError from "./IndexingError"; -import RequestError from "../errors/RequestError"; -import ParseError from "../errors/ParseError"; - -const STATE = { - /** - * When the process has not started yet, or has been stopped manually. - */ - IDLE: "idle", - /** - * When the indexing process is in progress. - */ - IN_PROGRESS: "in_progress", - /** - * When an error has occurred during the indexing process that has stopped the process. - */ - ERRORED: "errored", - /** - * When the indexing process has finished. - */ - COMPLETED: "completed", -}; /** * Indexes the site and shows a progress bar indicating the indexing process' progress. */ -class Indexation extends Component { - /** - * Indexing constructor. - * - * @param {Object} props The properties. - */ - constructor( props ) { - super( props ); - - this.settings = yoastIndexingData; - - this.state = { - state: STATE.IDLE, - processed: 0, - error: null, - amount: parseInt( this.settings.amount, 10 ), - firstTime: ( - this.settings.firstTime === "1" - ), - }; - - this.startIndexing = this.startIndexing.bind( this ); - this.stopIndexing = this.stopIndexing.bind( this ); - } - - /** - * Does an indexing request. - * - * @param {string} url The url of the indexing that should be done. - * @param {string} nonce The WordPress nonce value for in the header. - * - * @returns {Promise} The request promise. - */ - async doIndexingRequest( url, nonce ) { - const response = await fetch( url, { - method: "POST", - headers: { - "X-WP-Nonce": nonce, - }, - } ); - - const responseText = await response.text(); - - let data; - try { - /* - * Sometimes, in case of a fatal error, or if WP_DEBUG is on and a DB query fails, - * non-JSON is dumped into the HTTP response body, so account for that here. - */ - data = JSON.parse( responseText ); - } catch ( error ) { - throw new ParseError( "Error parsing the response to JSON.", responseText ); - } - - // Throw an error when the response's status code is not in the 200-299 range. - if ( ! response.ok ) { - const stackTrace = data.data ? data.data.stackTrace : ""; - throw new RequestError( data.message, url, "POST", response.status, stackTrace ); - } - - return data; - } - - /** - * Does any registered indexing action *before* a call to an index endpoint. - * - * @param {string} endpoint The endpoint that has been called. - * - * @returns {Promise} An empty promise. - */ - async doPreIndexingAction( endpoint ) { - if ( typeof this.props.preIndexingActions[ endpoint ] === "function" ) { - await this.props.preIndexingActions[ endpoint ]( this.settings ); - } - } - - /** - * Does any registered indexing action *after* a call to an index endpoint. - * - * @param {string} endpoint The endpoint that has been called. - * @param {Object} response The response of the call to the endpoint. - * - * @returns {Promise} An empty promise. - */ - async doPostIndexingAction( endpoint, response ) { - if ( typeof this.props.indexingActions[ endpoint ] === "function" ) { - await this.props.indexingActions[ endpoint ]( response.objects, this.settings ); - } - } - - /** - * Does the indexing of a given endpoint. - * - * @param {string} endpoint The endpoint. - * - * @returns {Promise} The indexing promise. - */ - async doIndexing( endpoint ) { - let url = this.settings.restApi.root + this.settings.restApi.indexing_endpoints[ endpoint ]; - - while ( this.isState( STATE.IN_PROGRESS ) && url !== false ) { - try { - await this.doPreIndexingAction( endpoint ); - const response = await this.doIndexingRequest( url, this.settings.restApi.nonce ); - await this.doPostIndexingAction( endpoint, response ); - - flushSync( () => { - this.setState( previousState => ( - { - processed: previousState.processed + response.objects.length, - firstTime: false, - } - ) ); - } ); - - url = response.next_url; - } catch ( error ) { - flushSync( () => { - this.setState( { - state: STATE.ERRORED, - error: error, - firstTime: false, - } ); - } ); - } - } - } - - /** - * Indexes the objects by calling each indexing endpoint in turn. - * - * @returns {Promise} The indexing promise. - */ - async index() { - for ( const endpoint of Object.keys( this.settings.restApi.indexing_endpoints ) ) { - await this.doIndexing( endpoint ); - } - /* - * Set the indexing process as completed only when there is no error - * and the user has not stopped the process manually. - */ - if ( ! this.isState( STATE.ERRORED ) && ! this.isState( STATE.IDLE ) ) { - this.completeIndexing(); - } - } - - /** - * Starts the indexing process. - * - * @returns {Promise} The start indexing promise. - */ - async startIndexing() { - /* - * Since `setState` is asynchronous in nature, we have to supply a callback - * to make sure the state is correctly set before trying to call the first - * endpoint. - */ - this.setState( { processed: 0, state: STATE.IN_PROGRESS }, this.index ); - } - - /** - * Sets the state of the indexing process to completed. - * - * @returns {void} - */ - completeIndexing() { - this.setState( { state: STATE.COMPLETED } ); - } - - /** - * Stops the indexing process. - * - * @returns {void} - */ - stopIndexing() { - this.setState( previousState => ( - { - state: STATE.IDLE, - processed: 0, - amount: previousState.amount - previousState.processed, - } - ) ); - } - - /** - * Start indexation on mount, when redirected from the "Start SEO data optimization" button in the dashboard notification. - * - * @returns {void} - */ - componentDidMount() { - if ( this.settings.disabled ) { - return; - } - - this.props.indexingStateCallback( this.state.amount === 0 ? "completed" : this.state.state ); - - const shouldStart = new URLSearchParams( window.location.search ).get( "start-indexation" ) === "true"; - - if ( shouldStart ) { - const currentURL = removeSearchParam( window.location.href, "start-indexation" ); - addHistoryState( null, document.title, currentURL ); - - this.startIndexing(); - } - } - - /** - * Signals state changes to an optional callback function. - * - * @param {Object} _prevProps The previous props, unused in the current implementation. - * @param {Object} prevState The previous state. - * - * @returns {void} - */ - componentDidUpdate( _prevProps, prevState ) { - if ( this.state.state !== prevState.state ) { - this.props.indexingStateCallback( this.state.state ); - } - } - - /** - * If the current state of the indexing process is the given state. - * - * @param {STATE.IDLE|STATE.ERRORED|STATE.IN_PROGRESS|STATE.COMPLETED} state The state value to check against. - * - * @returns {boolean} If the current state of the indexing process is the given state. - */ - isState( state ) { - return this.state.state === state; - } - +class Indexation extends AbstractIndexation { /** * Renders a notice if it is the first time the indexation is performed. * @@ -392,16 +140,4 @@ class Indexation extends Component { } } -Indexation.propTypes = { - indexingActions: PropTypes.object, - preIndexingActions: PropTypes.object, - indexingStateCallback: PropTypes.func, -}; - -Indexation.defaultProps = { - indexingActions: {}, - preIndexingActions: {}, - indexingStateCallback: () => {}, -}; - export default Indexation; diff --git a/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexation.js b/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexation.js index 677880bea6f..8060165c91a 100644 --- a/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexation.js +++ b/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexation.js @@ -1,268 +1,24 @@ /* global yoastIndexingData */ -import { Component, flushSync, Fragment } from "@wordpress/element"; +import { Fragment } from "@wordpress/element"; import { Transition } from "@headlessui/react"; import { __ } from "@wordpress/i18n"; import { Button } from "@yoast/ui-library"; import PropTypes from "prop-types"; import AnimateHeight from "react-animate-height"; -import { addHistoryState, removeSearchParam } from "../../../../helpers/urlHelpers"; +import AbstractIndexation, { STATE, indexationDefaultProps, indexationPropTypes } from "../../../../components/AbstractIndexation"; import IndexingError from "./indexing-error"; import Alert from "../../base/alert"; -import RequestError from "../../../../errors/RequestError"; -import ParseError from "../../../../errors/ParseError"; - -const STATE = { - /** - * When the process has not started yet, or has been stopped manually. - */ - IDLE: "idle", - /** - * When the indexing process is in progress. - */ - IN_PROGRESS: "in_progress", - /** - * When an error has occurred during the indexing process that has stopped the process. - */ - ERRORED: "errored", - /** - * When the indexing process has finished. - */ - COMPLETED: "completed", -}; /** * Indexes the site and shows a progress bar indicating the indexing process' progress. */ -class Indexation extends Component { - /** - * Indexing constructor. - * - * @param {Object} props The properties. - */ - constructor( props ) { - super( props ); - - this.settings = yoastIndexingData; - - this.state = { - state: STATE.IDLE, - processed: 0, - error: null, - amount: parseInt( this.settings.amount, 10 ), - firstTime: ( - this.settings.firstTime === "1" - ), - }; - - this.startIndexing = this.startIndexing.bind( this ); - this.stopIndexing = this.stopIndexing.bind( this ); - } - - /** - * Does an indexing request. - * - * @param {string} url The url of the indexing that should be done. - * @param {string} nonce The WordPress nonce value for in the header. - * - * @returns {Promise} The request promise. - */ - async doIndexingRequest( url, nonce ) { - const response = await fetch( url, { - method: "POST", - headers: { - "X-WP-Nonce": nonce, - }, - } ); - - const responseText = await response.text(); - - let data; - try { - /* - * Sometimes, in case of a fatal error, or if WP_DEBUG is on and a DB query fails, - * non-JSON is dumped into the HTTP response body, so account for that here. - */ - data = JSON.parse( responseText ); - } catch ( error ) { - throw new ParseError( "Error parsing the response to JSON.", responseText ); - } - - // Throw an error when the response's status code is not in the 200-299 range. - if ( ! response.ok ) { - const stackTrace = data.data ? data.data.stackTrace : ""; - throw new RequestError( data.message, url, "POST", response.status, stackTrace ); - } - - return data; - } - - /** - * Does any registered indexing action *before* a call to an index endpoint. - * - * @param {string} endpoint The endpoint that has been called. - * - * @returns {Promise} An empty promise. - */ - async doPreIndexingAction( endpoint ) { - if ( typeof this.props.preIndexingActions[ endpoint ] === "function" ) { - await this.props.preIndexingActions[ endpoint ]( this.settings ); - } - } - - /** - * Does any registered indexing action *after* a call to an index endpoint. - * - * @param {string} endpoint The endpoint that has been called. - * @param {Object} response The response of the call to the endpoint. - * - * @returns {Promise} An empty promise. - */ - async doPostIndexingAction( endpoint, response ) { - if ( typeof this.props.indexingActions[ endpoint ] === "function" ) { - await this.props.indexingActions[ endpoint ]( response.objects, this.settings ); - } - } - - /** - * Does the indexing of a given endpoint. - * - * @param {string} endpoint The endpoint. - * - * @returns {Promise} The indexing promise. - */ - async doIndexing( endpoint ) { - let url = this.settings.restApi.root + this.settings.restApi.indexing_endpoints[ endpoint ]; - - while ( this.isState( STATE.IN_PROGRESS ) && url !== false ) { - try { - await this.doPreIndexingAction( endpoint ); - const response = await this.doIndexingRequest( url, this.settings.restApi.nonce ); - await this.doPostIndexingAction( endpoint, response ); - - flushSync( () => { - this.setState( previousState => ( - { - processed: previousState.processed + response.objects.length, - firstTime: false, - } - ) ); - } ); - - url = response.next_url; - } catch ( error ) { - flushSync( () => { - this.setState( { - state: STATE.ERRORED, - error: error, - firstTime: false, - } ); - } ); - } - } - } - - /** - * Indexes the objects by calling each indexing endpoint in turn. - * - * @returns {Promise} The indexing promise. - */ - async index() { - for ( const endpoint of Object.keys( this.settings.restApi.indexing_endpoints ) ) { - await this.doIndexing( endpoint ); - } - /* - * Set the indexing process as completed only when there is no error - * and the user has not stopped the process manually. - */ - if ( ! this.isState( STATE.ERRORED ) && ! this.isState( STATE.IDLE ) ) { - this.completeIndexing(); - } - } - - /** - * Starts the indexing process. - * - * @returns {Promise} The start indexing promise. - */ - async startIndexing() { - /* - * Since `setState` is asynchronous in nature, we have to supply a callback - * to make sure the state is correctly set before trying to call the first - * endpoint. - */ - this.setState( { processed: 0, state: STATE.IN_PROGRESS }, this.index ); - } - - /** - * Sets the state of the indexing process to completed. - * - * @returns {void} - */ - completeIndexing() { - this.setState( { state: STATE.COMPLETED } ); - } - - /** - * Stops the indexing process. - * - * @returns {void} - */ - stopIndexing() { - this.setState( previousState => ( - { - state: STATE.IDLE, - processed: 0, - amount: previousState.amount - previousState.processed, - } - ) ); - } - +class Indexation extends AbstractIndexation { /** - * Start indexation on mount, when redirected from the "Start SEO data optimization" button in the dashboard notification. - * - * @returns {void} + * @inheritDoc */ - componentDidMount() { - if ( this.settings.disabled ) { - return; - } - - this.props.indexingStateCallback( this.state.amount === 0 ? "already_done" : this.state.state ); - - const shouldStart = new URLSearchParams( window.location.search ).get( "start-indexation" ) === "true"; - - if ( shouldStart ) { - const currentURL = removeSearchParam( window.location.href, "start-indexation" ); - addHistoryState( null, document.title, currentURL ); - - this.startIndexing(); - } - } - - /** - * Signals state changes to an optional callback function. - * - * @param {Object} _prevProps The previous props, unused in the current implementation. - * @param {Object} prevState The previous state. - * - * @returns {void} - */ - componentDidUpdate( _prevProps, prevState ) { - if ( this.state.state !== prevState.state ) { - this.props.indexingStateCallback( this.state.state ); - } - } - - /** - * If the current state of the indexing process is the given state. - * - * @param {STATE.IDLE|STATE.ERRORED|STATE.IN_PROGRESS|STATE.COMPLETED} state The state value to check against. - * - * @returns {boolean} If the current state of the indexing process is the given state. - */ - isState( state ) { - return this.state.state === state; + getAlreadyIndexedStateName() { + return "already_done"; } /** @@ -422,19 +178,16 @@ class Indexation extends Component { ); } } +/* eslint-enable complexity */ + Indexation.propTypes = { - indexingActions: PropTypes.object, - preIndexingActions: PropTypes.object, - indexingStateCallback: PropTypes.func, + ...indexationPropTypes, children: PropTypes.node, }; Indexation.defaultProps = { - indexingActions: {}, - preIndexingActions: {}, - indexingStateCallback: () => {}, + ...indexationDefaultProps, children: null, }; export default Indexation; -/* eslint-enable complexity */ From 7e9e0a522a2854a5de39291d61f2e34e5523952a Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 23 Jun 2026 14:09:55 +0200 Subject: [PATCH 02/11] Extract the indexing error content Nothing changed, I just moved out the re-used code --- packages/js/src/components/IndexingError.js | 83 +--------------- .../js/src/components/IndexingErrorContent.js | 95 +++++++++++++++++++ .../steps/indexation/indexing-error.js | 70 +------------- 3 files changed, 100 insertions(+), 148 deletions(-) create mode 100644 packages/js/src/components/IndexingErrorContent.js diff --git a/packages/js/src/components/IndexingError.js b/packages/js/src/components/IndexingError.js index 4b28d1d63c9..93494700195 100644 --- a/packages/js/src/components/IndexingError.js +++ b/packages/js/src/components/IndexingError.js @@ -1,75 +1,7 @@ -import { __ } from "@wordpress/i18n"; import { Alert } from "@yoast/components"; -import { strings } from "@yoast/helpers"; import PropTypes from "prop-types"; -import styled from "styled-components"; import RequestError from "../errors/RequestError"; - -const { stripTagsFromHtmlString } = strings; - -const ALLOWED_TAGS = [ "a", "p" ]; - -const ErrorDetails = styled.div` - margin-top: 8px; -`; - -const Preformatted = styled.pre` - overflow-x: scroll; - max-width: 500px; - border: 1px solid; - padding: 16px; -`; - -/** - * Shows a value for in the error details. - * - * If the value is `undefined`, nothing is shown. - * - * @param {string} title The label for the value. - * @param {any} [value=""] The value to show. - * - * @returns {JSX.Element} The error line component, or `null` if the value is `undefined`. - */ -function ErrorLine( { title, value = "" } ) { - if ( ! value ) { - return null; - } - return

- { title }
- { value } -

; -} - -ErrorLine.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.any, -}; - -/** - * Renders a collapsible error box. For bigger error messages or stack traces. - * - * @param {string} title The label for the value. - * @param {string} [value=""] The value. - * - * @returns {JSX.Element} The stack trace component, or `null` if no stack trace is available. - */ -function ErrorBox( { title, value = "" } ) { - if ( ! value ) { - return null; - } - - return
- { title } - - { value } - -
; -} - -ErrorBox.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.string, -}; +import IndexingErrorContent from "./IndexingErrorContent"; /** * An error that should be shown when indexation has failed. @@ -81,18 +13,7 @@ ErrorBox.propTypes = { */ export default function IndexingError( { message, error } ) { return -
-
- { __( "Error details", "wordpress-seo" ) } - - - - - - - - -
+ ; } diff --git a/packages/js/src/components/IndexingErrorContent.js b/packages/js/src/components/IndexingErrorContent.js new file mode 100644 index 00000000000..bd706cad5e9 --- /dev/null +++ b/packages/js/src/components/IndexingErrorContent.js @@ -0,0 +1,95 @@ +import { __ } from "@wordpress/i18n"; +import { strings } from "@yoast/helpers"; +import PropTypes from "prop-types"; +import RequestError from "../errors/RequestError"; + +const { stripTagsFromHtmlString } = strings; + +const ALLOWED_TAGS = [ "a", "p" ]; + +/** + * Shows a value for in the error details. + * + * If the value is `undefined`, nothing is shown. + * + * @param {string} title The label for the value. + * @param {any} [value=""] The value to show. + * + * @returns {JSX.Element|null} The error line component, or `null` if the value is `undefined`. + */ +function ErrorLine( { title, value = "" } ) { + if ( ! value ) { + return null; + } + return

+ { title }
+ { value } +

; +} + +ErrorLine.propTypes = { + title: PropTypes.string.isRequired, + value: PropTypes.any, +}; + +/** + * Renders a collapsible error box. For bigger error messages or stack traces. + * + * @param {string} title The label for the value. + * @param {string} [value=""] The value. + * + * @returns {JSX.Element|null} The stack trace component, or `null` if no stack trace is available. + */ +function ErrorBox( { title, value = "" } ) { + if ( ! value ) { + return null; + } + + return
+ { title } +
+			{ value }
+		
+
; +} + +ErrorBox.propTypes = { + title: PropTypes.string.isRequired, + value: PropTypes.string, +}; + +/** + * The body of an indexation error: the message plus the collapsible request/error details. + * + * Rendered inside an `Alert` by the styling-specific `IndexingError` wrappers; the inline styles + * keep it neutral so it works in both the style-guide and the Tailwind contexts. + * + * @param {string} message The error message to show. + * @param {Error|RequestError|ParseError} error The error itself. + * + * @returns {JSX.Element} The indexation error body. + */ +export default function IndexingErrorContent( { message, error } ) { + return <> +
+
+ { __( "Error details", "wordpress-seo" ) } +
+ + + + + + +
+
+ ; +} + +IndexingErrorContent.propTypes = { + message: PropTypes.string.isRequired, + error: PropTypes.oneOfType( [ + PropTypes.instanceOf( Error ), + PropTypes.instanceOf( RequestError ), + ] ).isRequired, +}; diff --git a/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexing-error.js b/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexing-error.js index 568326c417b..6bbc46841c1 100644 --- a/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexing-error.js +++ b/packages/js/src/first-time-configuration/tailwind-components/steps/indexation/indexing-error.js @@ -1,69 +1,16 @@ -import { __ } from "@wordpress/i18n"; -import { strings } from "@yoast/helpers"; import PropTypes from "prop-types"; import RequestError from "../../../../errors/RequestError"; +import IndexingErrorContent from "../../../../components/IndexingErrorContent"; import Alert from "../../base/alert"; -const { stripTagsFromHtmlString } = strings; - -const ALLOWED_TAGS = [ "a", "p" ]; - -/** - * Shows a value for in the error details. - * - * If the value is `undefined`, nothing is shown. - * - * @param {string} title The title of the thing. - * @param {any} [value=""] The value to show. - * @returns {JSX.Element|null} The error line component, or `null` if the value is `undefined`. - */ -function ErrorLine( { title, value = "" } ) { - if ( ! value ) { - return null; - } - return

- { title }
- { value } -

; -} - -ErrorLine.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.any, -}; - -/** - * Renders a collapsible error box. For bigger error messages or stack traces. - * - * @param {string} title The title of the element. - * @param {string} [value=""] The value. - * @returns {JSX.Element|null} The stack trace component, or `null` if no stack trace is available. - */ -function ErrorBox( { title, value = "" } ) { - if ( ! value ) { - return null; - } - - return
- { title } -
-			{ value }
-		
-
; -} - -ErrorBox.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.string, -}; - /** * An error that should be shown when indexation has failed. * * @param {string} message The error message to show. * @param {Error|RequestError|ParseError} error The error itself. * @param {string} [className=""] Optional class name. + * * @returns {JSX.Element} The indexation error component. */ export default function IndexingError( { message, error, className = "" } ) { @@ -71,18 +18,7 @@ export default function IndexingError( { message, error, className = "" } ) { type={ "error" } className={ className } > -
-
- { __( "Error details", "wordpress-seo" ) } -
- - - - - - -
-
+ ; } From 28f84132a13df576a0a19e5b4f68a1384a7ee035 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:29:21 +0200 Subject: [PATCH 03/11] Introduce an exception that will be thrown when an indexable fails to be created --- .../indexable/indexing-failed-exception.php | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/exceptions/indexable/indexing-failed-exception.php diff --git a/src/exceptions/indexable/indexing-failed-exception.php b/src/exceptions/indexable/indexing-failed-exception.php new file mode 100644 index 00000000000..85409b40aa9 --- /dev/null +++ b/src/exceptions/indexable/indexing-failed-exception.php @@ -0,0 +1,88 @@ +object_id = $object_id; + $this->object_type = $object_type; + $this->object_sub_type = $object_sub_type; + + parent::__construct( + \sprintf( + /* translators: 1: indexable object type; 2: object ID; 3: underlying error message. */ + 'Yoast SEO could not build the %1$s indexable for object %2$d: %3$s', + $object_type, + $object_id, + $previous->getMessage(), + ), + 0, + $previous, + ); + } + + /** + * Gets the object ID of the indexable that failed to build. + * + * @return int The object ID. + */ + public function get_object_id() { + return $this->object_id; + } + + /** + * Gets the object type of the indexable that failed to build. + * + * @return string The object type. + */ + public function get_object_type() { + return $this->object_type; + } + + /** + * Gets the object sub type of the indexable that failed to build. + * + * @return string|null The object sub type. + */ + public function get_object_sub_type() { + return $this->object_sub_type; + } +} From f00f0665c880d5635ee04ac14d7a6fd5c5c02fef Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:32:45 +0200 Subject: [PATCH 04/11] Throw an exception and log when an indexable creation fails --- src/builders/indexable-builder.php | 62 +++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/builders/indexable-builder.php b/src/builders/indexable-builder.php index 719b90a2f9b..2091f8a3dce 100644 --- a/src/builders/indexable-builder.php +++ b/src/builders/indexable-builder.php @@ -2,9 +2,12 @@ namespace Yoast\WP\SEO\Builders; +use Throwable; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Exceptions\Indexable\Not_Built_Exception; use Yoast\WP\SEO\Exceptions\Indexable\Source_Exception; use Yoast\WP\SEO\Helpers\Indexable_Helper; +use Yoast\WP\SEO\Loggers\Logger; use Yoast\WP\SEO\Models\Indexable; use Yoast\WP\SEO\Repositories\Indexable_Repository; use Yoast\WP\SEO\Services\Indexables\Indexable_Version_Manager; @@ -107,6 +110,13 @@ class Indexable_Builder { */ protected $version_manager; + /** + * The logger. + * + * @var Logger + */ + protected $logger; + /** * Returns the instance of this class constructed through the ORM Wrapper. * @@ -122,6 +132,7 @@ class Indexable_Builder { * @param Indexable_Helper $indexable_helper The indexable helper. * @param Indexable_Version_Manager $version_manager The indexable version manager. * @param Indexable_Link_Builder $link_builder The link builder for creating missing SEO links. + * @param Logger $logger The logger. */ public function __construct( Indexable_Author_Builder $author_builder, @@ -135,7 +146,8 @@ public function __construct( Primary_Term_Builder $primary_term_builder, Indexable_Helper $indexable_helper, Indexable_Version_Manager $version_manager, - Indexable_Link_Builder $link_builder + Indexable_Link_Builder $link_builder, + Logger $logger ) { $this->author_builder = $author_builder; $this->post_builder = $post_builder; @@ -149,6 +161,7 @@ public function __construct( $this->indexable_helper = $indexable_helper; $this->version_manager = $version_manager; $this->link_builder = $link_builder; + $this->logger = $logger; } /** @@ -254,8 +267,8 @@ public function build_for_system_page( $page_type, $indexable = false ) { /** * Ensures we have a valid indexable. Creates one if false is passed. * - * @param Indexable|false $indexable The indexable. - * @param array $defaults The initial properties of the Indexable. + * @param Indexable|false $indexable The indexable. + * @param array $defaults The initial properties of the Indexable. * * @return Indexable The indexable. */ @@ -302,13 +315,13 @@ protected function is_type_with_no_id( $type ) { return \in_array( $type, [ 'home-page', 'date-archive', 'post-type-archive', 'system-page' ], true ); } - // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Exceptions are handled by the catch statement in the method. + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Most exceptions are handled in the method; the unexpected-error catch deliberately re-throws after firing the failure hook. /** * Rebuilds an Indexable from scratch. * - * @param Indexable $indexable The Indexable to (re)build. - * @param array|null $defaults The object type of the Indexable. + * @param Indexable $indexable The Indexable to (re)build. + * @param array|null $defaults The object type of the Indexable. * * @return Indexable|false The resulting Indexable. */ @@ -408,6 +421,43 @@ public function build( $indexable, $defaults = null ) { return $this->indexable_helper->save_indexable( $indexable, $indexable_before ); } catch ( Not_Built_Exception $exception ) { return false; + } catch ( Throwable $exception ) { + $indexing_failed_exception = new Indexing_Failed_Exception( + $indexable->object_id, + $indexable->object_type, + $indexable->object_sub_type, + $exception, + ); + + $this->logger->error( + $indexing_failed_exception->getMessage(), + [ + 'object_id' => $indexable->object_id, + 'object_type' => $indexable->object_type, + 'object_sub_type' => $indexable->object_sub_type, + 'exception' => \get_class( $exception ), + ], + ); + + /** + * Fires when an indexable could not be built because of an unexpected error. + * + * This action lets third parties observe build failures themselves. + * + * @param int $object_id The object ID of the indexable that failed to build. + * @param string $object_type The object type of the indexable that failed to build. + * @param string|null $object_sub_type The object sub type of the indexable that failed to build. + * @param Throwable $exception The error that caused the failure. + */ + \do_action( + 'wpseo_indexable_indexing_failed', + $indexable->object_id, + $indexable->object_type, + $indexable->object_sub_type, + $exception, + ); + + throw $indexing_failed_exception; } } From 33370fa4dc22a29b88f18e4a15be8a307f91a77d Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:33:44 +0200 Subject: [PATCH 05/11] Catch the `Indexing_Failed_Exception` and return an error with object id and type to be consumed by the fronted --- src/routes/indexing-route.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/routes/indexing-route.php b/src/routes/indexing-route.php index 898907cefc3..93e705a4909 100644 --- a/src/routes/indexing-route.php +++ b/src/routes/indexing-route.php @@ -16,6 +16,7 @@ use Yoast\WP\SEO\Actions\Indexing\Post_Link_Indexing_Action; use Yoast\WP\SEO\Actions\Indexing\Term_Link_Indexing_Action; use Yoast\WP\SEO\Conditionals\No_Conditionals; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Helpers\Indexing_Helper; use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Main; @@ -418,6 +419,20 @@ public function can_index() { protected function run_indexation_action( Indexation_Action_Interface $indexation_action, $url ) { try { return parent::run_indexation_action( $indexation_action, $url ); + } catch ( Indexing_Failed_Exception $exception ) { + $this->indexing_helper->indexing_failed(); + + $previous = $exception->getPrevious(); + + return new WP_Error( + 'wpseo_error_indexing', + $exception->getMessage(), + [ + 'stackTrace' => ( $previous !== null ) ? $previous->getTraceAsString() : $exception->getTraceAsString(), + 'object_id' => $exception->get_object_id(), + 'object_type' => $exception->get_object_type(), + ], + ); } catch ( Exception $exception ) { $this->indexing_helper->indexing_failed(); From 5a84c561922cd2daa7e93bff7aa12dbf166c4f57 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:35:29 +0200 Subject: [PATCH 06/11] Let the command catch the `Indexing_Failed_Exception` and print to terminal which indexable cannot be created --- src/commands/index-command.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/commands/index-command.php b/src/commands/index-command.php index 4b860e16154..de6af7ee167 100644 --- a/src/commands/index-command.php +++ b/src/commands/index-command.php @@ -14,6 +14,7 @@ use Yoast\WP\SEO\Actions\Indexing\Indexing_Prepare_Action; use Yoast\WP\SEO\Actions\Indexing\Post_Link_Indexing_Action; use Yoast\WP\SEO\Actions\Indexing\Term_Link_Indexing_Action; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Helpers\Indexable_Helper; use Yoast\WP\SEO\Main; @@ -163,8 +164,8 @@ public static function get_namespace() { * * @when after_wp_load * - * @param array|null $args The arguments. - * @param array|null $assoc_args The associative arguments. + * @param array|null $args The arguments. + * @param array|null $assoc_args The associative arguments. * * @return void */ @@ -202,7 +203,7 @@ public function index( $args = null, $assoc_args = null ) { /** * Runs all indexation actions. * - * @param array $assoc_args The associative arguments. + * @param array $assoc_args The associative arguments. * * @return void */ @@ -258,8 +259,25 @@ protected function run_indexation_action( $name, Indexation_Action_Interface $in $limit = $indexation_action->get_limit(); $progress = Utils\make_progress_bar( 'Indexing ' . $name, $total ); do { - $indexables = $indexation_action->index(); - $count = \count( $indexables ); + try { + $indexables = $indexation_action->index(); + } catch ( Indexing_Failed_Exception $exception ) { + $progress->finish(); + + $previous = $exception->getPrevious(); + WP_CLI::error( + \sprintf( + 'Could not optimize %1$s #%2$d while indexing %3$s: %4$s', + $exception->get_object_type(), + $exception->get_object_id(), + $name, + ( $previous !== null ) ? $previous->getMessage() : $exception->getMessage(), + ), + ); + + return; + } + $count = \count( $indexables ); $progress->tick( $count ); \usleep( $interval ); Utils\wp_clear_object_cache(); From 9209a4fbc9e39fa26efae01e8dfd561ebd16f6fa Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:36:22 +0200 Subject: [PATCH 07/11] Add object id and type --- packages/js/src/components/AbstractIndexation.js | 7 +++++-- packages/js/src/errors/RequestError.js | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/js/src/components/AbstractIndexation.js b/packages/js/src/components/AbstractIndexation.js index ac191fbd415..4208843b7fe 100644 --- a/packages/js/src/components/AbstractIndexation.js +++ b/packages/js/src/components/AbstractIndexation.js @@ -99,8 +99,11 @@ class AbstractIndexation extends Component { // Throw an error when the response's status code is not in the 200-299 range. if ( ! response.ok ) { - const stackTrace = data.data ? data.data.stackTrace : ""; - throw new RequestError( data.message, url, "POST", response.status, stackTrace ); + const errorData = data.data || {}; + throw new RequestError( data.message, url, "POST", response.status, errorData.stackTrace, { + objectId: errorData.object_id, + objectType: errorData.object_type, + } ); } return data; diff --git a/packages/js/src/errors/RequestError.js b/packages/js/src/errors/RequestError.js index c906e8ab0de..f4dc1f57b3e 100644 --- a/packages/js/src/errors/RequestError.js +++ b/packages/js/src/errors/RequestError.js @@ -10,13 +10,18 @@ export default class RequestError extends Error { * @param {"POST"|"GET"|"PUT"|"DELETE"} method The HTTP method of the failed request. * @param {number} statusCode The status code of the failed request. * @param {string} stackTrace The stack trace. + * @param {Object} [failingObject] The object that could not be indexed, when the backend reported one. + * @param {number} [failingObject.objectId] The ID of the object that failed to be indexed. + * @param {string} [failingObject.objectType] The type of the object that failed to be indexed. */ - constructor( message, url, method, statusCode, stackTrace ) { + constructor( message, url, method, statusCode, stackTrace, failingObject = {} ) { super( message ); this.name = "RequestError"; this.url = url; this.method = method; this.statusCode = statusCode; this.stackTrace = stackTrace; + this.objectId = failingObject.objectId ?? null; + this.objectType = failingObject.objectType ?? null; } } From 583dc2185f5a1b8a22c9c93fa159bf20b162cd67 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:38:00 +0200 Subject: [PATCH 08/11] Show to the user which object failed to be indexed --- packages/js/src/components/IndexingErrorContent.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/js/src/components/IndexingErrorContent.js b/packages/js/src/components/IndexingErrorContent.js index bd706cad5e9..f144459091d 100644 --- a/packages/js/src/components/IndexingErrorContent.js +++ b/packages/js/src/components/IndexingErrorContent.js @@ -75,6 +75,10 @@ export default function IndexingErrorContent( { message, error } ) {
{ __( "Error details", "wordpress-seo" ) }
+ From e79865dbee1c8e2a5b1a28cd1a00e0b9e7384e10 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:38:23 +0200 Subject: [PATCH 09/11] Add test to check if the error message is correctly displayed --- packages/js/tests/indexation.test.js | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/js/tests/indexation.test.js b/packages/js/tests/indexation.test.js index 11045cce944..c8d6c1935bd 100644 --- a/packages/js/tests/indexation.test.js +++ b/packages/js/tests/indexation.test.js @@ -153,4 +153,43 @@ describe( "Indexation", () => { expect( postIndexingAction ).toHaveBeenCalledWith( response.objects, global.yoastIndexingData ); }, { timeout: 1000 } ); } ); + + it( "shows the failing object when the backend reports one", async() => { + global.yoastIndexingData = { + amount: 5, + restApi: { + root: "https://example.com/", + // eslint-disable-next-line camelcase + indexing_endpoints: { + posts: "indexing-endpoint", + }, + nonce: "nonsense", + }, + errorMessage: "An error message.", + }; + + global.fetch = jest.fn().mockImplementation( () => Promise.resolve( { + ok: false, + status: 500, + text: () => Promise.resolve( JSON.stringify( { + message: "Indexing failed.", + data: { + stackTrace: "the stack trace", + // eslint-disable-next-line camelcase + object_id: 42, + // eslint-disable-next-line camelcase + object_type: "post", + }, + } ) ), + } ) ); + + render( ); + fireEvent.click( screen.getByRole( "button" ) ); + + await waitFor( () => { + expect( screen.queryByText( "An error message." ) ).toBeInTheDocument(); + }, { timeout: 1000 } ); + + expect( screen.getByText( /post #42/ ) ).toBeInTheDocument(); + } ); } ); From a59727e77b3dd5f275a9b02ea3f63cab6aa8e39b Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:38:33 +0200 Subject: [PATCH 10/11] Update unit tests --- .../Abstract_Indexable_Builder_TestCase.php | 14 ++++- .../Builders/Indexable_Builder/Build_Test.php | 54 +++++++++++++++++++ .../Ensure_Indexable_Test.php | 1 + .../Is_Type_With_No_Id_Test.php | 5 +- .../Maybe_Build_Author_Indexable_Test.php | 1 + .../Indexable_Builder/Save_Indexable_Test.php | 3 +- tests/Unit/Commands/Index_Command_Test.php | 45 ++++++++++++++++ .../Indexing_Failed_Exception_Test.php | 43 +++++++++++++++ tests/Unit/Routes/Indexing_Route_Test.php | 21 ++++++++ 9 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Exceptions/Indexable/Indexing_Failed_Exception_Test.php diff --git a/tests/Unit/Builders/Indexable_Builder/Abstract_Indexable_Builder_TestCase.php b/tests/Unit/Builders/Indexable_Builder/Abstract_Indexable_Builder_TestCase.php index 910fdc0a592..667ee413b14 100644 --- a/tests/Unit/Builders/Indexable_Builder/Abstract_Indexable_Builder_TestCase.php +++ b/tests/Unit/Builders/Indexable_Builder/Abstract_Indexable_Builder_TestCase.php @@ -15,6 +15,7 @@ use Yoast\WP\SEO\Builders\Indexable_Term_Builder; use Yoast\WP\SEO\Builders\Primary_Term_Builder; use Yoast\WP\SEO\Helpers\Indexable_Helper; +use Yoast\WP\SEO\Loggers\Logger; use Yoast\WP\SEO\Repositories\Indexable_Repository; use Yoast\WP\SEO\Services\Indexables\Indexable_Version_Manager; use Yoast\WP\SEO\Tests\Unit\Doubles\Models\Indexable_Mock; @@ -137,6 +138,13 @@ abstract class Abstract_Indexable_Builder_TestCase extends TestCase { */ protected $version_manager; + /** + * The logger. + * + * @var Mockery\MockInterface|Logger + */ + protected $logger; + /** * Sets up the test. * @@ -158,6 +166,7 @@ protected function set_up() { $this->indexable_helper = Mockery::mock( Indexable_Helper::class ); $this->version_manager = Mockery::mock( Indexable_Version_Manager::class ); $this->indexable_repository = Mockery::mock( Indexable_Repository::class ); + $this->logger = Mockery::mock( Logger::class ); $this->indexable = Mockery::mock( Indexable_Mock::class ); $this->indexable->author_id = 1999; @@ -178,6 +187,7 @@ protected function set_up() { $this->indexable_helper, $this->version_manager, $this->link_builder, + $this->logger, ); $this->instance->set_indexable_repository( $this->indexable_repository ); @@ -214,8 +224,8 @@ public function expect_save_indexable( $indexable ) { /** * Expectations for ensure_indexable method. * - * @param array $defaults The defaults to expect. - * @param Indexable_Mock $return_indexable The indexable to expect. + * @param array $defaults The defaults to expect. + * @param Indexable_Mock $return_indexable The indexable to expect. * * @return void */ diff --git a/tests/Unit/Builders/Indexable_Builder/Build_Test.php b/tests/Unit/Builders/Indexable_Builder/Build_Test.php index 7c266764396..8d102085d3b 100644 --- a/tests/Unit/Builders/Indexable_Builder/Build_Test.php +++ b/tests/Unit/Builders/Indexable_Builder/Build_Test.php @@ -2,7 +2,10 @@ namespace Yoast\WP\SEO\Tests\Unit\Builders\Indexable_Builder; +use Brain\Monkey; use Mockery; +use RuntimeException; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Exceptions\Indexable\Invalid_Term_Exception; use Yoast\WP\SEO\Exceptions\Indexable\Post_Not_Found_Exception; use Yoast\WP\SEO\Exceptions\Indexable\Source_Exception; @@ -139,6 +142,57 @@ public function test_build_for_id_and_type_with_term_exception() { $this->assertFalse( $this->instance->build( $this->indexable ) ); } + /** + * Tests that an unexpected error while building is logged, fires the failure action and is thrown + * wrapped in an Indexing_Failed_Exception that carries the failing object, so the existing + * "stop the run and report it" behaviour is preserved. + * + * @covers ::build + * @covers ::deep_copy_indexable + * + * @return void + */ + public function test_build_logs_fires_action_and_throws_wrapped_exception_on_unexpected_error() { + $this->indexable->object_sub_type = 'page'; + + $this->expect_deep_copy_indexable( $this->indexable ); + + $exception = new RuntimeException( 'Something unexpected happened.' ); + + $this->post_builder + ->expects( 'build' ) + ->once() + ->with( 1337, $this->indexable ) + ->andThrow( $exception ); + + $this->logger + ->expects( 'error' ) + ->once() + ->with( + 'Yoast SEO could not build the post indexable for object 1337: Something unexpected happened.', + [ + 'object_id' => 1337, + 'object_type' => 'post', + 'object_sub_type' => 'page', + 'exception' => 'RuntimeException', + ], + ); + + Monkey\Actions\expectDone( 'wpseo_indexable_indexing_failed' ) + ->once() + ->with( 1337, 'post', 'page', $exception ); + + try { + $this->instance->build( $this->indexable ); + $this->fail( 'Expected an Indexing_Failed_Exception to be thrown.' ); + } catch ( Indexing_Failed_Exception $indexing_failed_exception ) { + $this->assertSame( 1337, $indexing_failed_exception->get_object_id() ); + $this->assertSame( 'post', $indexing_failed_exception->get_object_type() ); + $this->assertSame( 'page', $indexing_failed_exception->get_object_sub_type() ); + $this->assertSame( $exception, $indexing_failed_exception->getPrevious() ); + } + } + /** * Tests that build returns false when a build returns an exception. * diff --git a/tests/Unit/Builders/Indexable_Builder/Ensure_Indexable_Test.php b/tests/Unit/Builders/Indexable_Builder/Ensure_Indexable_Test.php index 58d84fc03ae..a9ea226589c 100644 --- a/tests/Unit/Builders/Indexable_Builder/Ensure_Indexable_Test.php +++ b/tests/Unit/Builders/Indexable_Builder/Ensure_Indexable_Test.php @@ -42,6 +42,7 @@ protected function set_up() { $this->indexable_helper, $this->version_manager, $this->link_builder, + $this->logger, ); $this->instance->set_indexable_repository( $this->indexable_repository ); diff --git a/tests/Unit/Builders/Indexable_Builder/Is_Type_With_No_Id_Test.php b/tests/Unit/Builders/Indexable_Builder/Is_Type_With_No_Id_Test.php index abf399748a6..3552d2b0a53 100644 --- a/tests/Unit/Builders/Indexable_Builder/Is_Type_With_No_Id_Test.php +++ b/tests/Unit/Builders/Indexable_Builder/Is_Type_With_No_Id_Test.php @@ -35,15 +35,16 @@ protected function set_up() { $this->indexable_helper, $this->version_manager, $this->link_builder, + $this->logger, ); $this->instance->set_indexable_repository( $this->indexable_repository ); } /** - * Provider for testing save_indexable method. + * Provider for testing is_type_with_no_id method. * - * @return array The test data. + * @return array> The test data. */ public static function is_type_with_no_id_provider() { return [ diff --git a/tests/Unit/Builders/Indexable_Builder/Maybe_Build_Author_Indexable_Test.php b/tests/Unit/Builders/Indexable_Builder/Maybe_Build_Author_Indexable_Test.php index 2c03814459e..ca33f1229f4 100644 --- a/tests/Unit/Builders/Indexable_Builder/Maybe_Build_Author_Indexable_Test.php +++ b/tests/Unit/Builders/Indexable_Builder/Maybe_Build_Author_Indexable_Test.php @@ -38,6 +38,7 @@ protected function set_up() { $this->indexable_helper, $this->version_manager, $this->link_builder, + $this->logger, ); $this->instance->set_indexable_repository( $this->indexable_repository ); diff --git a/tests/Unit/Builders/Indexable_Builder/Save_Indexable_Test.php b/tests/Unit/Builders/Indexable_Builder/Save_Indexable_Test.php index 77d6c4dfdc0..bc981ef53ec 100644 --- a/tests/Unit/Builders/Indexable_Builder/Save_Indexable_Test.php +++ b/tests/Unit/Builders/Indexable_Builder/Save_Indexable_Test.php @@ -36,6 +36,7 @@ protected function set_up() { $this->indexable_helper, $this->version_manager, $this->link_builder, + $this->logger, ); $this->instance->set_indexable_repository( $this->indexable_repository ); @@ -44,7 +45,7 @@ protected function set_up() { /** * Provider for testing save_indexable method. * - * @return array The test data. + * @return array> The test data. */ public static function save_indexable_provider() { $before = Mockery::mock( Indexable::class ); diff --git a/tests/Unit/Commands/Index_Command_Test.php b/tests/Unit/Commands/Index_Command_Test.php index 87b7ae4b501..9136ade5aa0 100644 --- a/tests/Unit/Commands/Index_Command_Test.php +++ b/tests/Unit/Commands/Index_Command_Test.php @@ -4,7 +4,9 @@ use Brain\Monkey; use cli\progress\Bar; +use Exception; use Mockery; +use RuntimeException; use WP_CLI; use wpdb; use Yoast\WP\SEO\Actions\Indexing\Indexable_General_Indexation_Action; @@ -16,6 +18,7 @@ use Yoast\WP\SEO\Actions\Indexing\Post_Link_Indexing_Action; use Yoast\WP\SEO\Actions\Indexing\Term_Link_Indexing_Action; use Yoast\WP\SEO\Commands\Index_Command; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Helpers\Indexable_Helper; use Yoast\WP\SEO\Main; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -209,6 +212,48 @@ public function test_execute() { $this->instance->index( null, [ 'interval' => 500 ] ); } + /** + * Tests that a failing object is reported through WP_CLI::error, which halts the run. + * + * @covers ::index + * @covers ::run_indexation_action + * + * @return void + */ + public function test_execute_reports_failing_object() { + $this->indexable_helper->expects( 'should_index_indexables' )->once()->andReturn( true ); + + $this->prepare_indexing_action->expects( 'prepare' )->once(); + + $this->post_indexation_action->expects( 'get_total_unindexed' )->once()->andReturn( 30 ); + $this->post_indexation_action->expects( 'get_limit' )->once()->andReturn( 25 ); + $this->post_indexation_action + ->expects( 'index' ) + ->once() + ->andThrow( new Indexing_Failed_Exception( 42, 'post', 'post', new Exception( 'Something broke.' ) ) ); + + // The run halts on WP_CLI::error, so completion is never reached. + $this->complete_indexation_action->expects( 'complete' )->never(); + + $progress_bar_mock = Mockery::mock( Bar::class ); + Monkey\Functions\expect( '\WP_CLI\Utils\make_progress_bar' ) + ->once() + ->with( Mockery::type( 'string' ), 30 ) + ->andReturn( $progress_bar_mock ); + $progress_bar_mock->expects( 'finish' )->once(); + $progress_bar_mock->expects( 'tick' )->never(); + + $cli = Mockery::mock( 'overload:' . WP_CLI::class ); + $cli->expects( 'error' ) + ->once() + ->with( 'Could not optimize post #42 while indexing posts: Something broke.' ) + ->andThrow( new RuntimeException( 'Halt.' ) ); + + $this->expectException( RuntimeException::class ); + + $this->instance->index( null, [ 'interval' => 500 ] ); + } + /** * Tests the execute function on a staging site. * diff --git a/tests/Unit/Exceptions/Indexable/Indexing_Failed_Exception_Test.php b/tests/Unit/Exceptions/Indexable/Indexing_Failed_Exception_Test.php new file mode 100644 index 00000000000..c08bc5b6bb6 --- /dev/null +++ b/tests/Unit/Exceptions/Indexable/Indexing_Failed_Exception_Test.php @@ -0,0 +1,43 @@ +assertSame( 123, $instance->get_object_id() ); + $this->assertSame( 'post', $instance->get_object_type() ); + $this->assertSame( 'page', $instance->get_object_sub_type() ); + $this->assertSame( $previous, $instance->getPrevious() ); + $this->assertSame( + 'Yoast SEO could not build the post indexable for object 123: The underlying error.', + $instance->getMessage(), + ); + } +} diff --git a/tests/Unit/Routes/Indexing_Route_Test.php b/tests/Unit/Routes/Indexing_Route_Test.php index dae2735479d..132e3050c72 100644 --- a/tests/Unit/Routes/Indexing_Route_Test.php +++ b/tests/Unit/Routes/Indexing_Route_Test.php @@ -16,6 +16,7 @@ use Yoast\WP\SEO\Actions\Indexing\Indexing_Prepare_Action; use Yoast\WP\SEO\Actions\Indexing\Post_Link_Indexing_Action; use Yoast\WP\SEO\Actions\Indexing\Term_Link_Indexing_Action; +use Yoast\WP\SEO\Exceptions\Indexable\Indexing_Failed_Exception; use Yoast\WP\SEO\Helpers\Indexing_Helper; use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Routes\Indexing_Route; @@ -514,4 +515,24 @@ public function test_index_general_when_error_occurs() { $this->instance->index_general(); } + + /** + * Tests that a failing object reported through an Indexing_Failed_Exception is turned into a + * WP_Error carrying that object's id and type. + * + * @covers ::run_indexation_action + * + * @return void + */ + public function test_index_posts_when_indexing_failed_exception_occurs() { + $exception = new Indexing_Failed_Exception( 123, 'post', 'post', new Exception( 'The underlying error.' ) ); + + $this->post_indexation_action->expects( 'index' )->once()->andThrow( $exception ); + + $this->indexing_helper->expects( 'indexing_failed' )->once()->withNoArgs(); + + Mockery::mock( WP_Error::class ); + + $this->assertInstanceOf( WP_Error::class, $this->instance->index_posts() ); + } } From 600e5a2a447a2a51875340344838cfe80928b4c5 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 24 Jun 2026 23:45:49 +0200 Subject: [PATCH 11/11] Lower error threshold --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 52ccba45ca3..f8ad3ee5fb7 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2391", + "@putenv YOASTCS_THRESHOLD_ERRORS=2383", "@putenv YOASTCS_THRESHOLD_WARNINGS=257", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ],