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')
+ })
+})