Commit 6221875
Search Blocks: add a No Results block for the search empty state (#51164)
* Search Blocks: add a No Results block for the search empty state
The empty-state copy lived in two plain-string attributes on the results-list
block and was escaped with esc_html() on render, so an author could not put a
link, an image, or any other markup in it. A partner site moving to Jetpack
Search lost the link its previous no-results page had (SEARCH-308).
Add jetpack-search/no-results, modelled on core/query-no-results: an
ancestor-scoped container whose inner blocks are whatever the author wants.
Core decides visibility server-side by re-running the query; the Search blocks
fetch client-side, so visibility is a data-wp-bind--hidden binding instead. A
filterState attribute ("Display when") lets a second block cover the
filters-active case, which core has no equivalent for.
Empty blocks fall back to the localized default copy rather than rendering
nothing, so the shipped templates can carry a self-closing block and still
translate. With the default filterState that fallback is the same two-<p>
hasActiveFilters swap results-list has always emitted, leaving stock installs
unchanged.
Saved content keeps working untouched: results-list still renders its legacy
message region, now gated on state.showLegacyNoResults. The new block seeds
state.hasNoResultsBlock, and because interactivity state is serialized once at
footer time that suppression resolves regardless of render order — no parent
walking, no HTML surgery, and the surviving message stays in its authored
position. The legacy inspector fields hand off to the block when one is present.
* Fix CI: phan suppressions and an i18n-safe editor label
Phan flagged `$content` as an undeclared global on the echo path and the
`(string)` cast as redundant — match the suppression shape the sibling
container blocks already use.
The production minifier folded the three `__()` calls in the canvas-label
helper into one call with a computed msgid, which i18n-check-webpack-plugin
rejects and which would have shipped the labels untranslatable. Keying the
labels off an object keeps each msgid a literal.
* Address review: seed no-results coverage per filter state
A lone No Results block scoped to one filter state seeded a flat
"a block exists" flag, which stood the legacy results-list message down for
both states — so an unfiltered empty search on a template whose only block was
scoped to "Filters are active" rendered no empty state at all. Seed the states
each block actually covers instead; the legacy region now fills whatever is
left, and two scoped blocks still compose into full coverage because
wp_interactivity_state() deep-merges and nothing seeds false. The results-list
inspector mirrors it — only the covered field hands off.
Also from review:
- Scope the layout presentation (centering, 12rem floor, 36ch measure, muted
opacity and font size) to the default-copy modifier. Applied to authored
content it squashed exactly the Columns rows, full-width images and buttons
the block exists to allow. A stock install is unchanged.
- Keep role="status" on the default fallback only. Announcing an author's whole
composition verbatim on every empty search is worse than not announcing it,
and core/query-no-results has no live region.
- Give the default copy one source per side — Search_Blocks::no_results_default_messages()
and blocks/default-messages.js — rather than four literals that can drift
into near-identical translator strings.
- Drop spacing.blockGap, which had no layout support to attach to.
- getBlockCount instead of getBlocks for the emptiness check, reset the
interactivity singleton between tests in No_Results_Render_Test, bump the
results-list block version, and use margin-block-end.
AGENTS.md: correct the claim that a block can never see its siblings — a
container can and does (the powered-by auto-append), it just can't help here,
since the interactivity store is a single page-global namespace.
* Address review: let a scoped No Results block win over an unscoped one
An unscoped block bound to `showNoResults` and a scoped one bound to
`showNoResultsFiltered` were both visible on a filtered empty search — and that
is the pairing the UI steers authors toward, since every shipped template
carries an unscoped block and the inspector help says to add a second one for
the filters-active case.
Scoped blocks now additionally claim their state, and the unscoped block yields
wherever something more specific claimed it — the same relationship it already
has with the legacy results-list region, one level up. Two unscoped blocks
stacking stays author error, as it is for core/query-no-results.
Also from review:
- Pass a single client id to getClientIdsOfDescendants. The installed
@wordpress/block-editor normalizes both shapes and the hand-off was verified
working either way, but the string form is the documented signature. The jest
mock is argument-aware now so a wrong call shape can't pass unnoticed.
- Return flat booleans from the results-list useSelect mapper. A fresh nested
object failed isShallowEqual on every editor store change, re-rendering the
block on each keystroke anywhere in the canvas.
- Reset the interactivity singleton in tearDown as well as setUp, so the last
test's seeded coverage can't escape into the next class.
* Search Blocks: deprecate the Results List no-results settings
The No Results block is now the way to author the search empty state, so
the two message fields on Results List are no longer offered in the
inspector — a short note points authors at the block instead, plus a
shortcut to select it when the results region already has one.
The attributes stay registered and `render.php` keeps emitting them.
Dropping an attribute makes the block parser discard it on load, which
would silently blank the empty state on every page saved before the
block existed; `state.showLegacyNoResults` already keeps the legacy
region and the block from both showing.
With the inspector fields gone, `blocks/default-messages.js` had a
single consumer left, so it folds back into `no-results/edit.jsx`.
* Search Blocks: cover the deprecated no-results attributes
The compat path had a hole: `noResultsWithFiltersMessage` was not even
registered in the render test's inline harness, so nothing exercised the
filtered variant an author may have customized before the No Results
block existed. Adds custom-copy, whitespace-fallback, and escaping cases
for it, plus one asserting both messages render together bound to the
complementary `state.hasActiveFilters` branches — the pair that makes the
legacy region filter-aware.
Also guards the two things that would silently break the compat path:
`block.json` still declaring both attributes (the parser discards what it
does not recognize, which would blank the empty state on load), and the
editor never clearing them while another setting changes.
Each new assertion was checked against a mutated source to confirm it
fails when the behavior it describes is removed.
* Fix CI: cast json_decode before array access in the block.json guard
* Search Blocks: offer to add a No Results block from Results List
A layout saved before the block existed keeps rendering the deprecated
message, and with the text fields gone its empty state now reads as
unconfigurable. The Results List panel says so directly: when the results
region has no No Results block it explains the empty state is a plain
message and offers an "Add No Results block" button; when it has one, the
existing "Edit No Results block" shortcut.
The block inserts as Results List's own next sibling rather than being
appended to the `search-results` ancestor, so a nested layout (a Group
around the list) doesn't put it somewhere the author didn't expect.
* Search Blocks: use logical properties in the No Results default styles
* Search Blocks: trim No Results "Display when" to two options
"No filters are active" earned less than it cost. Because an unscoped
block already yields wherever a `filtered` one claimed the state, the
`any` + `filtered` pairing expresses every combination an author needs —
one message everywhere, or different content per filter state. The third
value only said "show nothing on a filtered search", and even that did
not hold, since the uncovered state falls through to the legacy region's
default copy anyway.
Drops `showNoResultsUnfiltered` and `hasScopedNoResultsUnfiltered`
outright, and collapses the render-side coverage seeding to a single
branch. Still covers both no-results messages Kathryn asked to keep
customizable on SEARCH-308.
* Search Blocks: cover the error state with the No Results block
Kathryn asked for this in all three messages, not just the two no-results
ones. Rather than a second near-identical block, `filterState` gains a
`Search failed` option: `showNoResults` requires `! hasError` and
`showError` requires `hasError`, so the states are mutually exclusive in
the store and one block with one binding per option can never
double-render.
The error value is disjoint from the two no-results ones in both
directions — it seeds only `hasErrorBlock`, retiring `results-list`'s
legacy error region via the new `showLegacyError`, and an `any` block
never seeds it. That keeps `any` the safe default: a page whose only
block is unscoped still shows the legacy error copy when a request fails,
so no template change is needed. The fallback also swaps to
`role="alert"`, matching the assertive/polite split `results-list`
already emitted between its two regions.
`errorMessage` is deprecated the same way as the other two, which leaves
the Results List panel with no message fields at all.
* Search Blocks: describe Results List without the error message
The block description still claimed Results List renders the error
message; it hands both the empty and error states to No Results now. The
string shows in the editor sidebar, so it was visible in the screenshots.
* Search Blocks: hold each No Results condition in its own variant
One block per condition meant an author who wanted different copy for a
filtered search had to find the inserter, add a second No Results block
and know to scope it — and List View then showed two entries with the
same name, since a per-instance label needs `__experimentalLabel`, which
this tree avoids on principle.
`no-results` becomes a container of `no-results-slot` variants, one per
condition. Adding a condition is a button in the container's inspector,
so the author never leaves the block. `allowedBlocks` is the variant
alone and the default appender is suppressed — it would insert a second
`any` variant that the renderer would stack on the first — and a button
disappears once a variant covers its condition.
An empty container still stands in for an unscoped variant: same
filter-aware default pair, same seeded coverage. That keeps the shipped
templates' self-closing `<!-- wp:jetpack-search/no-results /-->` working
untouched, verified against a live env in all three conditions.
Four helpers on `Search_Blocks` are now the single implementation the
container's fallback and the variant share, rather than the condition
logic living twice in two render.php files.
* Search Blocks: show every No Results condition by default
Starting the container empty behind "add a variant" buttons hid the two
conditions an author most needs to know exist. It now creates a variant
per condition up front, and the templates and pattern carry the same
three, so opening one doesn't auto-insert anything and mark it dirty on
mere inspection. An author sees all three empty states, edits the ones
they care about, and leaves the rest to render their localized default —
so a stock install still reads exactly as it did before the block.
The inspector buttons stay as a recovery path for a deleted condition.
Also from review:
- Front-end styles move to the container. A variant only renders inside
one, but not the reverse, and `no-results-slot.css` is only enqueued
when a variant actually renders — so the stock template's default copy
had lost its muted/centered/12rem treatment entirely. Confirmed
against a live env before and after.
- Inner blocks that aren't variants are wrapped as one unscoped variant
rather than emitted bare, which put them behind the region-level
binding and showed them on a failed request too.
- `showLegacyNoResults` had lost its docblock to `showLegacyError`.
* Search Blocks: describe No Results by what it ships with
The description still told authors to add a variant per condition; the
block now creates one per condition up front.
* Search Blocks: nest the No Results variant inside its parent block
The variant only ever renders inside `no-results`, so it now lives in
`blocks/no-results/slot/` rather than beside the block it belongs to —
the relationship is visible in the tree instead of implied by a name.
`register_blocks()` walks one level into each registered block directory
for nested `block.json` files, which also means a child inherits its
parent's gating for free: a skipped parent never reaches the recursion.
Nesting it made an existing redundancy obvious, so the variant drops its
view module too. It carries interactivity directives but no behavior of
its own, and the container's view module already registers the shared
store — which is guaranteed loaded, since a variant cannot render outside
one. That leaves the variant with no build output at all.
* Search Blocks: fix the PHP 7.2 matrix and cover block discovery
`assertMatchesRegularExpression()` landed in PHPUnit 9.1, and the PHP 7.2
CI matrix still runs PHPUnit 8.5, so both new render tests errored there
while passing locally on 8.5.9. Swapped for `preg_match()` — the same
workaround `ai-answer` and `clear-filters` already document.
The nested-registration walk had no test, which the coverage check
caught. Extracted it as `block_directories()` so what gets registered is
assertable without standing up WordPress, and pinned the three properties
that matter: a child directory is discovered, a parent is listed before
its child, and a gated-off parent takes its children with it. Verified by
deleting the descent and watching two of them fail.
* Search Blocks: select through the block-editor store object
Review feedback: the three edit components reached for the store by its
'core/block-editor' string. Swapped for the exported store object, which
keeps the dependency explicit and survives a store rename.
The data mocks ignored the selector's argument, so nothing would have
caught a regression here. They now record what the component hands to
`select()`, and each of the three suites asserts it is the store the
component imported — verified by putting the string back and watching the
assertion fail.
* Search Blocks: keep the overlay pre-render out of the page's coverage flags
`enqueue_block_template_overlay_assets()` runs `do_blocks()` over the overlay
template on every front-end request, so its variants were seeding the
page-global `hasNoResults*` / `hasErrorBlock` flags whether or not a visitor
ever opened the overlay. A page that also carries pre-block results markup —
a `results-list` with an authored `noResultsMessage` and no No Results block
of its own — then had its legacy region retired by markup parked in an inert
`<template>`, and a search through the in-page input left the results area
blank where the author's copy used to be.
The overlay knows its own composition, so it no longer needs the flags: the
pass reads coverage off the parsed template up front, `seed_no_results_coverage()`
no-ops while it is set, and the legacy regions resolve server-side — dropped
outright where a variant covers them, bound to the plain condition getters
where one doesn't. The page's own render path is unchanged.
* Search Blocks: collapse a No Results container with nothing to show
The container carries the block's color and spacing supports and unhides for
the whole empty-state region, while each variant hides itself per condition.
A container missing the live condition — a deleted variant, or two variants
pointed at the same one — therefore painted an author's background and padding
around nothing. The inline comment called that harmless on the grounds that
block supports are opt-in, which the supports the block advertises contradict.
Collapse it in CSS instead, keyed on the render-time binding so the rule can't
reach the editor canvas, where an empty container has to stay selectable.
* Search Blocks: anchor the pre-hydration hidden assertions per element
`/<div[^>]*\shidden\s*>/` was anchored to nothing and stopped at the first
match, but the default render emits two divs carrying a bare `hidden` — the
container and the default copy's wrapper. Either one could lose the attribute
and the test would still pass, while the region flashed on hydration exactly
as the test name says it must not.
* Search Blocks: fold the No Results changelog entries into one per project
Four entries described one feature. Adds the plugin-side entries too — the
block ships to users through both Jetpack and Jetpack Search.
* Search Blocks: drop the No Results variant's "Display when" control
The setting is left over from when the block was one block per condition. The
container now seeds a variant for every condition, the templates ship all
three, and the container's inspector re-adds a deleted one with the right
value — so the radio could only ever move a variant onto a condition another
already covers, stacking two messages there and leaving the vacated one to
fall back to the legacy region.
`condition` stays a registered attribute; it just has no editing affordance,
the same shape as the deprecated Results List message settings. This also
closes the second way to reach an empty container: the only remaining one is
deleting a variant, which the CSS collapse already covers.
* Search Blocks: move the empty-state logic to a No_Results class
The condition helpers had accumulated into ten methods and a static on
`Search_Blocks`, which is already 3,700 lines of registration, gating and
template plumbing they have nothing to do with. They are one cohesive thing —
which renderer covers which condition, the copy, and the store getter that
reveals it — so they get their own class.
The overlay's scope handling comes out better for it. `render_self_contained()`
owns the whole invariant (collect coverage, render, always clear) rather than
leaving the caller to set a static and remember a `finally`, and it gives the
tests a real seam: the coverage and leak assertions now go through the public
method instead of reaching for the private static with Reflection.
Also trims the AGENTS.md notes that came with the last two commits.
* Search Blocks: collapse an untouched No Results message in the editor
Three stacked previews, each with its own appender, pushed the rest of the
search template off-screen — and only one of the three can ever be on screen
for a visitor. An untouched message is now a single labelled line and expands
when selected. One holding authored content always renders in full, so nothing
an author wrote is ever behind the compact form.
* Fix CI: read the coverage static into a local before indexing it
Phan doesn't narrow a static property through a `null !==` guard, so all four
array accesses tripped PhanTypeArraySuspiciousNullable. A local reads better
than repeating the property name anyway.
* Address review: wrap stray blocks that sit beside No Results variants
`$has_variants` was a boolean over all inner blocks, so one variant was enough
to send the whole of `$content` out bare — non-variant siblings included. The
wrap-as-unscoped path only ran when there were *zero* variants, which is not
what the comment beside it (or AGENTS.md) claimed.
Two ways that bit. On a failed request the container unhides for the error
condition, no variant matches, and the stray content renders anyway — exactly
the "shows on an error too" outcome the wrapping exists to prevent. And because
a stray carries no `hidden`, `:not(:has(> :not([hidden])))` can never match, so
the container also keeps painting its background and padding around it.
`$content` is one concatenated string, so the strays can't be lifted back out
of it — the mixed case re-renders each child in document order instead and
gathers runs of strays into an unscoped variant. Re-rendering re-seeds, which
is idempotent. The all-variants path still echoes `$content` untouched.
Reachable through the document Code Editor, a paste, or an import:
`allowedBlocks` governs the block UI, not the markup.
* Address review: restore the self-contained scope instead of clearing it
The `finally` reset to null rather than to the previous value. Nothing nests
today — the only caller fires once on `wp_enqueue_scripts` — but AGENTS.md
invites more callers, and a nested one would have failed silently and badly:
the outer render drops back onto the page-global getters *and* resumes seeding,
which is the leak this class exists to prevent.
Also notes the one blind spot in `collect_coverage()`: `parse_blocks()` sees a
synced pattern as a leaf, so a block reachable only through one reads as
uncovered. Dereferencing refs would cost a post fetch on every front-end
request for a shape the editor can't produce, so it's documented rather than
resolved.
* Address review: keep a drop target on a collapsed No Results message
Collapsing unmounted `InnerBlocks` entirely, and the inner drop target comes
from `useInnerBlocksProps` — so dragging a block from the inserter onto a
collapsed message resolved to the container instead, whose `allowedBlocks` is
the variant alone and rejects it. Since a stock container renders all three
collapsed, that was every drag on a fresh template.
It stays mounted now; only the appender goes. Empty and appender-less it has no
size, so the one-line row is unchanged.
* Address review: two Results List inaccuracies
The render docblock still described the error region as gated by
`state.showError`; it moved to `state.showLegacyError`. And the empty-state
help text rendered outside a Search Results ancestor, where the button it
refers to is suppressed — so the panel named an action it wasn't offering.
* Fix CI: declare the probe array's keys for static analysis
The closure fills them by reference from a `render_block` filter, which Phan
can't follow — it saw writes to an array it had typed as empty.
* Search Blocks: collapse authored No Results messages too
Only one condition can ever be on screen for a visitor, so three stacked
messages are three times the height the page will ever use — and an authored
message is taller than the default-copy preview, not shorter. Every message now
collapses when it isn't selected, not just the untouched ones.
The row summarises an authored message with its own first line of copy (depth
first, so a paragraph inside a Group still reads), so it still says what's in
there rather than going blank. The blocks are hidden rather than unmounted:
`useInnerBlocksProps` is where the inner drop target comes from, and an empty
variant has no size to hide anyway.
* Search Blocks: give No Results messages a layout so content can be aligned
A message takes arbitrary blocks, but alignment is a property of the layout its
parent provides — with none, an author couldn't centre an image inside one and
`alignwide`/`alignfull` never appeared in the toolbar. Constrained layout, the
same shape core/group uses, so children get a content width and the alignment
controls mean something. `blockGap` comes back with it, for the same reason it
went away: now there's a layout to attach it to.
The stock empty state is unchanged — it has no author content to align, and its
own centering is its own CSS.
* Fix CodeQL: strip tags until the result stops changing
`<[^>]*>` in one pass turns `<scr<a>ipt>` into `<script>` — the tags it removes
reassembling into a new one. Not exploitable here (the summary is a React text
node, so it's escaped either way), but it is an incomplete sanitizer and CodeQL
is right to flag it.
A rich-text attribute already carries plain text as `.text`, so use that when
it's there and loop the strip until stable otherwise.
* Search Blocks: outline and name the No Results block in the editor
Three dashed rows with nothing around them read as three unrelated things. An
outline and a label group them as one block, so it's clear which conditions
belong to it.
* Address review: announce authored empty-state copy too
The live region wrapped only the localized fallback, on the reasoning that
reading out an author's whole composition is worse than reading nothing. That
trade was wrong: `results-list` put its custom `noResultsMessage` inside
`role="status"` and its custom `errorMessage` inside `role="alert"`, so an
author who moves their copy into the block lost the announcement they had.
Results arrive client-side, so there is no page load to announce it instead —
a screen reader user just gets silence where results used to be.
Every message carries the live region now, authored or not, keeping the
assertive/polite split by condition.
* Address review: count stray children as covering the unscoped condition
`render.php` wraps stray children as an unscoped variant, but `collect_coverage()`
only counted variants — so the two disagreed about what a container covers. In a
self-contained render that put the wrapped strays and `results-list`'s legacy
region on the same getter, showing both at once on an unfiltered empty search.
* Search Blocks: correct a stale note about who owns the error state
`search-results/edit.jsx` still said `results-list` owns the error message
internally. It is a No Results condition now.
---------
Co-authored-by: Jasper Kang <jasper@Jaspers-MacBook-Pro16.local>
Committed via a GitHub action: https://github.com/Automattic/jetpack/actions/runs/31658606462
Upstream-Ref: Automattic/jetpack@7d04ad71 parent e0df9fe commit 6221875
49 files changed
Lines changed: 738 additions & 124 deletions
File tree
- jetpack_vendor
- automattic/jetpack-search
- build
- search-blocks-editor
- search-blocks
- store
- src
- search-blocks
- blocks
- no-results
- slot
- results-list
- patterns
- templates
- vendor
- composer
- jetpack-autoloader
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
12 | 15 | | |
13 | 16 | | |
14 | 17 | | |
| |||
331 | 334 | | |
332 | 335 | | |
333 | 336 | | |
334 | | - | |
| 337 | + | |
335 | 338 | | |
336 | 339 | | |
337 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
| 55 | + | |
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | | - | |
| 29 | + | |
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
12 | 15 | | |
13 | 16 | | |
14 | 17 | | |
| |||
1876 | 1879 | | |
1877 | 1880 | | |
1878 | 1881 | | |
1879 | | - | |
| 1882 | + | |
1880 | 1883 | | |
1881 | 1884 | | |
1882 | 1885 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
0 commit comments