Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 42 additions & 11 deletions src/js/hooks/useSubmitSnippet.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,6 +25,36 @@ const snippetMessages = {
const conditionCreated = __('Condition <strong>created</strong>.', 'code-snippets')
const conditionUpdated = __('Condition <strong>updated</strong>.', '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'),
Expand Down Expand Up @@ -105,31 +135,32 @@ export const useSubmitSnippet = (): UseSubmitSnippet => {
request.active = false
}

const result = await (async (): Promise<Snippet | string | undefined> => {
const result = await (async (): Promise<Snippet | SubmitFailure> => {
try {
const { id } = request

const response = await (undefined === id || 0 === id
? 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)
}
})()

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
}

Expand Down
13 changes: 12 additions & 1 deletion src/js/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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'
)
}
Expand Down
64 changes: 64 additions & 0 deletions tests/e2e/editor-save-outcomes.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading