diff --git a/CHANGELOG.md b/CHANGELOG.md index 9107201d8..3233fb62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ was switched off. * Fixed saving, activating or deactivating a snippet reporting its previous state, which could leave a deactivated snippet running under file-based execution. +* Fixed the snippet editor reporting a save as failed and undelivered when no response was received, which it cannot + determine. ## [3.10.2] (2026-09-01) diff --git a/src/js/hooks/useSubmitSnippet.tsx b/src/js/hooks/useSubmitSnippet.tsx index 4ad2de6a3..9d93055cd 100644 --- a/src/js/hooks/useSubmitSnippet.tsx +++ b/src/js/hooks/useSubmitSnippet.tsx @@ -1,7 +1,7 @@ import { __ } from '@wordpress/i18n' import { isAxiosError } from 'axios' import React, { useCallback } from 'react' -import { describeRequestError } from '../utils/errors' +import { describeRequestError, isUnconfirmedRequest } from '../utils/errors' import { useSnippetForm } from '../components/EditMenu/SnippetForm/WithSnippetFormContext' import { createSnippetObject, isCondition } from '../utils/snippets/snippets' import { buildUrl } from '../utils/urls' @@ -25,6 +25,36 @@ const snippetMessages = { const conditionCreated = __('Condition created.', 'code-snippets') const conditionUpdated = __('Condition updated.', 'code-snippets') +/** A save that did not produce a snippet, and whether its outcome is actually known. */ +interface SubmitFailure { + message?: string + unconfirmed: boolean +} + +const isSubmitFailure = (result: Snippet | SubmitFailure): result is SubmitFailure => + 'unconfirmed' in result + +interface SubmitFailureNotice { + failure: SubmitFailure + messages: typeof snippetMessages + isUpdate: boolean +} + +/** + * Word a save that did not return a snippet. + * + * A request that never received a response says nothing about whether the write + * happened, so it is reported on its own rather than behind a heading stating + * the snippet was not saved. + */ +const describeSubmitFailure = ({ failure, messages, isUpdate }: SubmitFailureNotice): string => + failure.unconfirmed + ? failure.message ?? '' + : [ + isUpdate ? messages.failedUpdate : messages.failedCreate, + failure.message ?? __('The server did not send a valid response.', 'code-snippets') + ].filter(Boolean).join(' ') + const conditionMessages: typeof snippetMessages = { addNew: __('Create New Condition', 'code-snippets'), edit: __('Edit Condition', 'code-snippets'), @@ -105,7 +135,7 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { request.active = false } - const result = await (async (): Promise => { + const result = await (async (): Promise => { try { const { id } = request @@ -113,9 +143,12 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { ? api.create(request) : api.update({ ...request, id })) - return response.id ? createSnippetObject(response) : undefined + return response.id ? createSnippetObject(response) : { unconfirmed: false } } catch (error: unknown) { - return isAxiosError(error) ? describeRequestError(error) : undefined + return { + message: isAxiosError(error) ? describeRequestError(error) : undefined, + unconfirmed: isUnconfirmedRequest(error) + } } finally { setIsWorking(false) } @@ -123,13 +156,11 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { const messages = isCondition(snippet) ? conditionMessages : snippetMessages - if (undefined === result || 'string' === typeof result) { - const message = [ - request.id ? messages.failedUpdate : messages.failedCreate, - result ?? __('The server did not send a valid response.', 'code-snippets') - ] - - setCurrentNotice(['error', message.filter(Boolean).join(' ')]) + if (isSubmitFailure(result)) { + setCurrentNotice([ + 'error', + describeSubmitFailure({ failure: result, messages, isUpdate: Boolean(request.id) }) + ]) return undefined } diff --git a/src/js/utils/errors.ts b/src/js/utils/errors.ts index 2367c01db..1f35d7da5 100644 --- a/src/js/utils/errors.ts +++ b/src/js/utils/errors.ts @@ -29,6 +29,16 @@ export const unpackErrorResponse = (error: unknown): string => { return __('An unknown error occurred.', 'code-snippets') } +/** + * Whether a request finished without the browser receiving a response. + * + * Nothing can be concluded about the write itself from this: the request may + * never have arrived, or it may have been handled and the response lost on the + * way back. Aborts are excluded, as the caller stopped those deliberately. + */ +export const isUnconfirmedRequest = (error: unknown): boolean => + isAxiosError(error) && !error.response && !isAbortError(error) + /** * Explain a failed request in terms the reader can act on. * @@ -44,7 +54,8 @@ export const describeRequestError = (error: unknown): string => { if (!error.response) { return __( - 'The request did not reach your site. Check your connection, or whether a security plugin is blocking it.', + 'No response came back, so this could not be confirmed. The change may already have been saved — ' + + 'check in another tab before trying again.', 'code-snippets' ) } diff --git a/tests/e2e/editor-save-outcomes.spec.ts b/tests/e2e/editor-save-outcomes.spec.ts new file mode 100644 index 000000000..31a536919 --- /dev/null +++ b/tests/e2e/editor-save-outcomes.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { TIMEOUTS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' + +/** + * A save whose response never reaches the browser proves nothing about whether + * the snippet was written. The editor used to report it as a definite failure + * that never reached the site, which sent people back to re-save work that had + * in fact been saved. + */ + +const PREFIX = 'E2E Save Outcome' + +/** Matches the snippets collection and single-snippet routes, on pretty and plain permalinks alike. */ +const isSnippetWriteRequest = (url: string): boolean => + url.includes('rest_route=%2Fcode-snippets%2Fv1%2Fsnippets') || + url.includes('/wp-json/code-snippets/v1/snippets') + +test.describe('Reporting a save whose outcome is unknown', () => { + let helper: SnippetsTestHelper + let snippetName: string + + test.beforeEach(async ({ page }) => { + helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName(PREFIX) + + await wpCli(['eval', ` + $snippet = new \\Code_Snippets\\Model\\Snippet([ + 'name' => ${JSON.stringify(snippetName)}, + 'code' => ${JSON.stringify('// original\n')}, + 'scope' => 'global', + 'active' => false, + ]); + echo \\Code_Snippets\\save_snippet($snippet)->id; + `]) + }) + + test.afterEach(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(PREFIX) + }) + + test('does not claim the request never arrived, or that nothing was saved', async ({ page }) => { + await helper.openSnippet(snippetName) + + // Drop the save on its way back, leaving the browser without a response. + await page.route(url => isSnippetWriteRequest(url.toString()), route => route.abort('failed')) + + await helper.saveSnippet() + + const notice = page.locator('.code-snippets-notice.error, .snippet-editor-sidebar .notice.error').first() + await expect(notice).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await expect(notice, 'the reader should be told the outcome is unconfirmed') + .toContainText('could not be confirmed') + + const text = await notice.textContent() ?? '' + + expect(text, 'nothing establishes that the request failed to arrive') + .not.toContain('did not reach your site') + expect(text, 'nothing establishes that the snippet was left unsaved') + .not.toContain('Could not update snippet') + }) +})