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 @@ -67,6 +67,9 @@
* Fixed featured Community Cloud snippets failing to load with some cloud API responses. (PRO)
* Fixed bulk actions in Community Cloud running against an empty selection, so selected snippets were never downloaded.
(PRO)
* 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.

## [3.10.2] (2026-09-01)

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 @@ -84,9 +84,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 @@ -104,15 +108,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 @@ -156,6 +168,8 @@ const SnippetsTableInner = () => {

<SafeModeNotice />

<ListErrorNotice />

{currentType && !isLicensed() && isProType(currentType)
? <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
53 changes: 50 additions & 3 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 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 @@
{ codemirror: getPreviewEditorSettings(type) }
)

editorRef.current = instance.codemirror as EditorFromTextArea

// 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 () => {
editorRef.current = undefined;
(instance.codemirror as EditorFromTextArea).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
// afterwards. 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 @@
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 @@ -206,7 +222,8 @@
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,7 +266,37 @@
extraActions?: (working: boolean) => ReactNode
}

export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippet, setIsOpen, extraActions }) => {
/**
* 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)

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 }) => {

Check warning on line 298 in src/js/components/common/snippets/SnippetPreviewModal.tsx

View workflow job for this annotation

GitHub Actions / lint / stylelint, eslint, phpcs

Arrow function has too many lines (51). Maximum allowed is 50

Check warning on line 298 in src/js/components/common/snippets/SnippetPreviewModal.tsx

View workflow job for this annotation

GitHub Actions / lint / stylelint, eslint, phpcs

Arrow function has too many lines (51). Maximum allowed is 50
const snippet = useSnippetWithCode(listSnippet)
const { refreshSnippetsList } = useSnippetsList()
const { isWorking, setIsWorking } = useWorkingState()

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
31 changes: 27 additions & 4 deletions src/js/hooks/useSnippetsList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'
import { __ } from '@wordpress/i18n'
import { createContextHook } from '../utils/bootstrap'
import { isNetworkAdmin } from '../utils/screen'
import { parseSnippetObject } from '../utils/snippets/objects'
Expand All @@ -8,7 +9,17 @@ import type { Snippet } from '../types/Snippet'

export interface SnippetsListContext {
snippetsList: readonly Snippet[] | undefined
/**
* Whether the complete list has arrived from the API. Until it has, the list
* is whatever was embedded in the page, which is capped and may be partial,
* so nothing should present it as the whole library.
*/
isListLoaded: boolean
/** Set when the list could not be fetched, so the screen can say so rather than show a partial list as if it were complete. */
listError: string | undefined
refreshSnippetsList: () => Promise<void>
/** Ask for snippet code, which the list omits. Only searching code contents needs it. */
ensureSnippetCode: () => void
}

const [Context, useSnippetsList] = createContextHook<SnippetsListContext>('useSnippetsList')
Expand All @@ -18,16 +29,25 @@ export const WithSnippetsListContext: React.FC<PropsWithChildren> = ({ children
const [snippetsList, setSnippetsList] = useState<Snippet[] | undefined>(
() => window.CODE_SNIPPETS_MANAGE?.snippetsList?.map(parseSnippetObject)
)
const [isListLoaded, setIsListLoaded] = useState(false)
const [listError, setListError] = useState<string>()
const [withCode, setWithCode] = useState(false)

// Flipping this re-runs the fetch below, so code is loaded through the same
// path as everything else rather than racing a second request against it.
const ensureSnippetCode = useCallback(() => setWithCode(true), [])

const refreshSnippetsList = useCallback(async (): Promise<void> => {
try {
console.info('Fetching snippets list')
const response = await fetchAll(isNetworkAdmin())
const response = await fetchAll(isNetworkAdmin(), { withCode })
setSnippetsList(response)
setIsListLoaded(true)
setListError(undefined)
} catch (error: unknown) {
console.error('Error fetching snippets list', error)
setListError(__('Could not load the complete list of snippets, so some may be missing below.', 'code-snippets'))
}
}, [fetchAll])
}, [fetchAll, withCode])

useEffect(() => {
refreshSnippetsList()
Expand All @@ -36,7 +56,10 @@ export const WithSnippetsListContext: React.FC<PropsWithChildren> = ({ children

const value: SnippetsListContext = {
snippetsList,
refreshSnippetsList
isListLoaded,
listError,
refreshSnippetsList,
ensureSnippetCode
}

return <Context.Provider value={value}>{children}</Context.Provider>
Expand Down
Loading
Loading