Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
* Fixed the version list refresh reporting success when the versions could not be fetched.
* Fixed safe mode failing on sites running a plugin that builds URLs while WordPress is identifying the current user,
and breaking REST API URLs. Safe mode is now carried on admin links only.
* Fixed the snippets list showing only the first 100 snippets on sites with larger libraries, with the type counts
agreeing with that shortened list rather than the real total. The list no longer carries snippet code, making the
request far smaller, and the screen now reports a problem instead of presenting a partial list as complete.

## [4.0.0] (2026-10-12)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ const CloneExportMenuItems: React.FC<SnippetCardActionsProps> = ({ snippet }) =>
<>
<KebabMenuItem
onSelect={() => {
api.create(cloneSnippetObject(snippet))
api.ensureCode(snippet)
.then(full => api.create(cloneSnippetObject(full)))
.then(refreshSnippetsList)
.catch(handleUnknownError)
}}
Expand Down
4 changes: 3 additions & 1 deletion src/js/components/ManageMenu/SnippetsTable/RowActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ const ActionLinks: React.FC<RowActionsProps> = ({ snippet }) => {
? <SnippetActionButton
label={__('Clone', 'code-snippets')}
workingLabel={__('Cloning…', 'code-snippets')}
action={() => api.create(cloneSnippetObject(snippet)).then(refreshSnippetsList)}
action={() => api.ensureCode(snippet)
.then(full => api.create(cloneSnippetObject(full)))
.then(refreshSnippetsList)}
/>
: null

Expand Down
24 changes: 19 additions & 5 deletions src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,13 @@ const SafeModeNotice = () =>
</Notice>
: null

// Counts render immediately from localized values, then switch to live values.
// Counts render immediately from the values localized with the page, then switch
// to live values derived from the snippets list once it has loaded. The switch is
// driven by isListLoaded rather than by the list being present, because the list
// starts out holding the capped set embedded in the page: counting that would
// quietly report a truncated library as the whole of it.
const useSnippetTypeCounts = () => {
const { snippetsList } = useSnippetsList()
const { snippetsList, isListLoaded } = useSnippetsList()

const countedSnippets = useMemo(
() => snippetsList?.filter(snippet => !snippet.trashed),
Expand All @@ -119,15 +123,23 @@ const useSnippetTypeCounts = () => {
const localized = window.CODE_SNIPPETS_MANAGE?.typeCounts

const getCount = useCallback(
(type?: SnippetType) => countedSnippets
? type ? typeCounts?.get(type) ?? 0 : countedSnippets.length
(type?: SnippetType) => isListLoaded
? type ? typeCounts?.get(type) ?? 0 : countedSnippets?.length ?? 0
: localized?.[type ?? 'all'],
[countedSnippets, typeCounts, localized]
[isListLoaded, countedSnippets, typeCounts, localized]
)

return { getCount }
}

const ListErrorNotice: React.FC = () => {
const { listError } = useSnippetsList()

return listError
? <Notice type="error"><p>{listError}</p></Notice>
: null
}

const SnippetsTableInner = () => {
const { snippetView, setSnippetView } = useSnippetView()
const { currentType } = useSnippetsFilters()
Expand Down Expand Up @@ -175,6 +187,8 @@ const SnippetsTableInner = () => {

<SafeModeNotice />

<ListErrorNotice />

{isUpsell
? <UpsellPage />
: <WithFilteredSnippetsContext>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { createContextHook } from '../../../utils/bootstrap'
import { parseSnippetObject } from '../../../utils/snippets/objects'
import { getSnippetType, isSnippetActive } from '../../../utils/snippets/snippets'
Expand Down Expand Up @@ -48,9 +48,17 @@ export interface FilteredSnippetsContext {
const [Context, useFilteredSnippets] = createContextHook<FilteredSnippetsContext>('useFilteredSnippets')

export const WithFilteredSnippetsContext: React.FC<PropsWithChildren> = ({ children }) => {
const { snippetsList } = useSnippetsList()
const { snippetsList, ensureSnippetCode } = useSnippetsList()
const { currentType, currentTag, searchLineNumber, searchQueryText } = useSnippetsFilters()

// The list is fetched without snippet code, so ask for it the moment a search
// could match against it. Until it arrives, code simply matches nothing.
useEffect(() => {
if (searchQueryText?.trim()) {
ensureSnippetCode()
}
}, [searchQueryText, ensureSnippetCode])

const snippets = useMemo(
() => snippetsList ?? window.CODE_SNIPPETS_MANAGE?.snippetsList?.map(parseSnippetObject) ?? [],
[snippetsList])
Expand Down
115 changes: 87 additions & 28 deletions src/js/components/common/snippets/SnippetPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export interface PreviewModalProps {

export const PreviewModal: React.FC<PreviewModalProps> = ({ onRequestClose, title, type, code, children }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const editorRef = useRef<EditorFromTextArea>()
const contents = `${'php' === type ? '<?php\n\n' : ''}${code}`

useEffect(() => {
if (!textareaRef.current || !window.wp.codeEditor) {
Expand All @@ -127,16 +129,30 @@ export const PreviewModal: React.FC<PreviewModalProps> = ({ onRequestClose, titl
{ codemirror: getPreviewEditorSettings(type) }
)

editorRef.current = instance.codemirror

// CodeMirror hides the labeled source textarea and creates an unlabelled
// internal input. The screenReaderLabel option only exists from CodeMirror
// 5.59, while WordPress 5.5 ships 5.29, so label the input directly.
instance.codemirror.getInputField().setAttribute('aria-label', __('Snippet code preview', 'code-snippets'))

return () => {
(instance.codemirror as EditorFromTextArea).toTextArea()
editorRef.current = undefined
instance.codemirror.toTextArea()
}
}, [type])

// The editor is created as soon as the modal opens, but a snippet opened from
// the list arrives without its body and is fetched, so the code can turn up
// afterward. Keep the editor showing whatever the current code is.
useEffect(() => {
const editor = editorRef.current

if (editor && editor.getValue() !== contents) {
editor.setValue(contents)
}
}, [contents])

return (
<Modal
className="code-snippets-preview-modal"
Expand All @@ -153,7 +169,7 @@ export const PreviewModal: React.FC<PreviewModalProps> = ({ onRequestClose, titl
ref={textareaRef}
readOnly
aria-label={__('Snippet code preview', 'code-snippets')}
defaultValue={`${'php' === type ? '<?php\n\n' : ''}${code}`}
defaultValue={contents}
/>
</div>
{children}
Expand Down Expand Up @@ -195,6 +211,38 @@ interface ActionButtonProps {
setIsWorking: (isWorking: boolean) => void
}

interface TrashButtonProps extends ActionButtonProps {
setIsOpen: (isOpen: boolean) => void
}

const TrashButton = ({ snippet, isWorking, setIsWorking, setIsOpen }: TrashButtonProps) => {
const { refreshSnippetsList } = useSnippetsList()

const { requestDelete, deleteDialogProps } = useDeleteSnippet({
snippet,
setIsWorking,
onSuccess: () => {
setIsOpen(false)
return refreshSnippetsList()
},
onError: handleUnknownError
})

return !snippet.locked && canModifySnippet(snippet) && (
<>
<Button
link
className="code-snippets-preview-modal__trash"
disabled={isWorking}
onClick={() => void requestDelete()}
>
{__('Trash', 'code-snippets')}
</Button>

<ConfirmDeleteDialog {...deleteDialogProps} />
</>)
}

interface CloneButtonProps extends ActionButtonProps {
setIsOpen: (isOpen: boolean) => void
}
Expand All @@ -206,7 +254,8 @@ const CloneButton: React.FC<CloneButtonProps> = ({ snippet, isWorking, setIsWork
const handleClone = () => {
setIsWorking(true)

api.create(cloneSnippetObject(snippet))
api.ensureCode(snippet)
.then(full => api.create(cloneSnippetObject(full)))
.then(refreshSnippetsList)
.then(() => setIsOpen(false))
.catch(handleUnknownError)
Expand Down Expand Up @@ -249,22 +298,42 @@ export interface SnippetPreviewModalProps {
extraActions?: (working: boolean) => ReactNode
}

export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippet, setIsOpen, extraActions }) => {
const { refreshSnippetsList } = useSnippetsList()
const { isWorking, setIsWorking } = useWorkingState()
/**
* The snippets list is fetched without code, so a snippet opened from it arrives
* with an empty body. Fetch that one snippet so the preview has something to show.
*/
const useSnippetWithCode = (snippet: Snippet): Snippet => {
const api = useSnippetsAPI()
const [resolved, setResolved] = useState(snippet)

const { requestDelete, deleteDialogProps } = useDeleteSnippet({
snippet,
setIsWorking,
onSuccess: () => {
setIsOpen(false)
return refreshSnippetsList()
},
onError: handleUnknownError
})
useEffect(() => {
let cancelled = false

api.ensureCode(snippet)
.then(full => {
if (!cancelled) {
setResolved(full)
}
})
.catch(handleUnknownError)

return () => {
cancelled = true
}
// Refetching whenever the api object changes identity would loop; the snippet is what matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snippet])

return resolved
}

export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippet: listSnippet, setIsOpen, extraActions }) => {
const snippet = useSnippetWithCode(listSnippet)
const { isWorking, setIsWorking } = useWorkingState()
const canModify = canModifySnippet(snippet)

const actionButtonProps: ActionButtonProps = { snippet, isWorking, setIsWorking }

return (
<PreviewModal
code={snippet.code}
Expand All @@ -280,20 +349,11 @@ export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippe
: __('Edit', 'code-snippets')}
</a>

{canModify && <CloneButton snippet={snippet} isWorking={isWorking} setIsWorking={setIsWorking} setIsOpen={setIsOpen} />}
{canModify && <CloneButton {...actionButtonProps} setIsOpen={setIsOpen} />}

<ExportButton snippet={snippet} isWorking={isWorking} setIsWorking={setIsWorking} />
<ExportButton {...actionButtonProps} />
<CopyCodeButton code={snippet.code} />

{!snippet.locked && canModify && (
<Button
link
className="code-snippets-preview-modal__trash"
disabled={isWorking}
onClick={() => void requestDelete()}
>
{__('Trash', 'code-snippets')}
</Button>)}
<TrashButton {...actionButtonProps} setIsOpen={setIsOpen} />
</div>

<div className="code-snippets-preview-modal__priority">
Expand All @@ -302,7 +362,6 @@ export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippe
</div>

{extraActions?.(isWorking)}
<ConfirmDeleteDialog {...deleteDialogProps} />
</div>
</PreviewModal>
)
Expand Down
41 changes: 38 additions & 3 deletions src/js/hooks/useSnippetsAPI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ import type { SnippetIdentifierSchema, SnippetSchema, WritableSnippetSchema } fr
import type { RestAPI } from './useRestAPI'
import type { PropsWithChildren } from 'react'

export interface FetchAllOptions {
/**
* Whether to include each snippet's code. The list screen never renders it,
* so it is left out by default; only searching code contents needs it.
*/
withCode?: boolean
}

export interface SnippetsAPI {
fetchAll: (network?: boolean | null) => Promise<Snippet[]>
fetchAll: (network?: boolean | null, options?: FetchAllOptions) => Promise<Snippet[]>
fetch: (snippetId: number, network?: boolean | null) => Promise<Snippet>
/**
* Resolve a snippet that may have come from the list, which omits code.
* Anything needing the body — cloning, previewing, copying — goes through
* this so it works whichever source the snippet came from.
*/
ensureCode: (snippet: Snippet) => Promise<Snippet>
create: (snippet: Partial<Snippet>) => Promise<Snippet>
update: (snippet: Pick<Snippet, 'id' | 'network'> & Partial<Snippet>) => Promise<Snippet>
delete: (snippet: Pick<Snippet, 'id' | 'network'>) => Promise<void>
Expand All @@ -25,6 +39,18 @@ export interface SnippetsAPI {
detach: (snippet: Pick<Snippet, 'id' | 'network'>) => Promise<void>
}

/**
* Fields the snippets list needs, which is every schema field except `code`.
* A list carrying full snippet bodies is by far the largest thing the manage
* screen transfers and grows without bound as a library does, while nothing in
* the list renders it. Keep in step with the item schema.
*/
const LIST_FIELDS = [
'id', 'name', 'desc', 'tags', 'scope', 'condition_id', 'active', 'trashed', 'locked',
'priority', 'network', 'shared_network', 'modified', 'last_active', 'created_by', 'updated_by',
'code_error', 'code_error_trace'
].join(',')

const buildSnippetUrl = ({ id, network }: Pick<Snippet, 'id' | 'network'>, action?: string) =>
buildUrl([REST_BASES.snippets, id, action].filter(Boolean).join('/'), { network })

Expand Down Expand Up @@ -57,14 +83,23 @@ const mapToSchema = ({
})

const buildSnippetsAPI = ({ get, post, del, put }: RestAPI): SnippetsAPI => ({
fetchAll: network =>
get<SnippetSchema[]>(buildUrl(REST_BASES.snippets, { network }))
fetchAll: (network, options) =>
get<SnippetSchema[]>(buildUrl(REST_BASES.snippets, {
network,
...options?.withCode ? {} : { _fields: LIST_FIELDS }
}))
.then(response => response.map(createSnippetObject)),

fetch: (snippetId, network) =>
get<SnippetSchema>(buildUrl(`${REST_BASES.snippets}/${snippetId}`, { network }))
.then(createSnippetObject),

ensureCode: snippet =>
snippet.code
? Promise.resolve(snippet)
: get<SnippetSchema>(buildUrl(`${REST_BASES.snippets}/${snippet.id}`, { network: snippet.network }))
.then(createSnippetObject),

create: snippet =>
post<SnippetSchema, WritableSnippetSchema>(REST_BASES.snippets, mapToSchema(snippet))
.then(createSnippetObject),
Expand Down
Loading
Loading