Skip to content

Search: store foundation for static-filter URL contract (SEARCH-219, 1/3)#49038

Merged
kangzj merged 5 commits into
trunkfrom
search-219-static-filter-store-foundation
May 22, 2026
Merged

Search: store foundation for static-filter URL contract (SEARCH-219, 1/3)#49038
kangzj merged 5 commits into
trunkfrom
search-219-static-filter-store-foundation

Conversation

@kangzj
Copy link
Copy Markdown
Contributor

@kangzj kangzj commented May 21, 2026

First of three sequential PRs for SEARCH-219.

Part What ships Status
1/3 (this PR) JS store plumbing — staticFilterSelections slice, scalar URL serialization, ES term clauses. Inert until a block contributes a kind:'static' filterConfig entry. ⏳ this PR
2/3 PHP Filter_Static helper + /wp-json/jetpack-search/v1/static-filters REST endpoint + the staticFilterSelections seed slot. next
3/3 The jetpack-search/filter-static block + editor wiring + user-visible changelog entry. last

Proposed changes

  • store/url-state.jsstateToUrlParams serializes staticFilterSelections as scalar ?filter_id=value params, gated on filterConfigs[key].kind === 'static'. urlParamsToState mirrors the parse direction; only keys whose filterConfigs entry is registered as kind=static land in the slice, so plugin-emitted scalar params can't pollute it.
  • store/index.js — new staticFilterSelections state slice (sibling of priceRange), setStaticFilter (replace, not toggle) and onStaticFilterChange actions, gateStaticFilterSelections helper. Slice is threaded through syncToUrl, handlePopState, clearFilters, initialize() re-gating, the search-request builder, and the hasActiveFilters / activeFilterCount getters.
  • store/api.jsbuildStaticFilterClauses emits ES term clauses keyed by the static filter id; folded into the existing bool.must pipeline alongside dynamic facet filters, static post-type clauses, and the price range.

URL contract matches the legacy instant-search overlay's static-filter shape (scalar, single value per key) so deep links round-trip between the two surfaces. Precedent: priceRange is a sibling slice with the same shape (see search-blocks/AGENTS.md "URL format").

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

This PR is internal plumbing — nothing visible without parts 2 and 3. The new Jest cases exercise the surface:

  • pnpm exec jest tests/js/search-blocks/url-state.test.js — 63 cases passing (8 new for the static-filter branch).
  • pnpm exec jest tests/js/search-blocks/store.test.js — covers gateStaticFilterSelections, setStaticFilter replace/clear, clearFilters reset.
  • pnpm exec jest tests/js/search-blocks/api.test.js — covers buildStaticFilterClauses plus buildSearchUrl integration alone and combined with dynamic filters.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the search-219-static-filter-store-foundation branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack search-219-static-filter-store-foundation

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions Bot added [Package] Search Contains core Search functionality for Jetpack and Search plugins [Tests] Includes Tests labels May 21, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

This comment has been minimized.

Copilot finished work on behalf of kangzj May 21, 2026 04:49
@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented May 21, 2026

Code Coverage Summary

Coverage changed in 3 files.

File Coverage Δ% Δ Uncovered
projects/packages/search/src/search-blocks/store/index.js 452/528 (85.61%) -0.05% 5 💔
projects/packages/search/src/search-blocks/store/url-state.js 83/84 (98.81%) 0.14% 0 💚
projects/packages/search/src/search-blocks/store/api.js 180/185 (97.30%) 0.71% -1 💚

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

kangzj added a commit that referenced this pull request May 21, 2026
…StaticFilterSelections return shape (PR #49038)

- Move the orphaned setFilter() JSDoc block back to sit immediately above
  the setFilter() declaration. The insertion of setStaticFilter() pushed
  the block out of place; JSDoc processors attach to the next decl, so
  setFilter() was effectively undocumented.
- Add RESERVED_PARAMS guard to stateToUrlParams' static-filter
  serializer so a misconfigured filter whose filter_id matches a
  reserved param (s / orderby / min_price / max_price) can't silently
  overwrite the search query / sort / price params. Mirrors
  urlParamsToState which already filtered these.
- Align gateStaticFilterSelections' return shape with gateActiveFilters:
  now returns { gated, droppedAny } so callers in handlePopState() and
  initialize() can skip the state write when the gate was a no-op. The
  prior length-comparison fallback in initialize() collapses to a single
  boolean check.

Addresses claude[bot]'s review on PR #49038.

<!-- jp-loop -->
@kangzj

This comment has been minimized.

kangzj added 2 commits May 21, 2026 17:02
Adds the JS store plumbing the upcoming `jetpack-search/filter-static`
block depends on. Foundation-only — inert until a block contributes a
`kind: 'static'` filterConfig entry.

- `store/url-state.js`: `stateToUrlParams` accepts a
  `staticFilterSelections` slice and serializes each `kind === 'static'`
  entry as a scalar `?filter_id=value` param (no `[]` suffix).
  `urlParamsToState` mirrors the parse direction — scalar params land in
  `staticFilterSelections` only when their key is registered as
  kind=static, so plugin-emitted scalar params can't pollute state.
- `store/index.js`: new `staticFilterSelections` state slice,
  `setStaticFilter` (replace, not toggle) and `onStaticFilterChange`
  actions, `gateStaticFilterSelections` helper. Slice threaded through
  `syncToUrl`, `handlePopState`, `clearFilters`, `initialize()`
  re-gating, the search-request builder, and the `hasActiveFilters` /
  `activeFilterCount` getters.
- `store/api.js`: `buildStaticFilterClauses` emits ES `term` clauses
  keyed by the static filter id; folded into the existing `bool.must`
  pipeline alongside dynamic facet filters, static post-type clauses,
  and the price range.

URL contract matches the legacy instant-search overlay's static-filter
shape (scalar, single value per key) so deep links round-trip between
the two surfaces. Precedent: `priceRange` is a sibling slice with the
same shape (see `search-blocks/AGENTS.md` "URL format").

Test coverage:
- url-state.test.js (+8 cases): serialize gated on kind=static; parse
  pulls only configured keys; ignores unrelated scalar params; empty
  values dropped; last-write-wins on duplicate params.
- store.test.js (+7 cases): setStaticFilter replace/clear; clearFilters
  resets the slice; gateStaticFilterSelections rule set including a
  prototype-pollution defence.
- api.test.js (+6 cases): buildStaticFilterClauses gating + empty-value
  drop; buildSearchUrl integration alone and combined with dynamic
  filters.
…StaticFilterSelections return shape (PR #49038)

- Move the orphaned setFilter() JSDoc block back to sit immediately above
  the setFilter() declaration. The insertion of setStaticFilter() pushed
  the block out of place; JSDoc processors attach to the next decl, so
  setFilter() was effectively undocumented.
- Add RESERVED_PARAMS guard to stateToUrlParams' static-filter
  serializer so a misconfigured filter whose filter_id matches a
  reserved param (s / orderby / min_price / max_price) can't silently
  overwrite the search query / sort / price params. Mirrors
  urlParamsToState which already filtered these.
- Align gateStaticFilterSelections' return shape with gateActiveFilters:
  now returns { gated, droppedAny } so callers in handlePopState() and
  initialize() can skip the state write when the gate was a no-op. The
  prior length-comparison fallback in initialize() collapses to a single
  boolean check.

Addresses claude[bot]'s review on PR #49038.

<!-- jp-loop -->
@kangzj kangzj force-pushed the search-219-static-filter-store-foundation branch from 3abcffa to dbca1bf Compare May 21, 2026 05:02
@kangzj kangzj removed the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label May 21, 2026
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label May 21, 2026

This comment has been minimized.

Copilot finished work on behalf of kangzj May 21, 2026 05:04
@kangzj

This comment has been minimized.

@kangzj kangzj added the [Status] Needs Team Review Obsolete. Use Needs Review instead. label May 21, 2026
…g (PR #49038)

Block-side static-filter radios need a reactive checked binding so they
stay in sync with the store across paths that mutate
state.staticFilterSelections outside the radio's own change event — the
clearFilters() reset and the popstate-driven URL rewind. Without this,
radios visually keep their last clicked state even after the store has
cleared the slice.

The callback reads the per-<li> context (filterKey + optionValue) that
the filter-static block's render.php will emit, and compares against
state.staticFilterSelections[filterKey]. Each radio in render.php then
binds:
  data-wp-bind--checked="callbacks.isStaticFilterSelected"

Server-rendered `checked` still seeds first paint; this takes over
after hydration.

Surfaced during the review cycle on PR #49040.

<!-- jp-loop -->
@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

kangzj added a commit that referenced this pull request May 21, 2026
…nt (PR #49040)

Two findings from claude[bot]'s re-review on PR #49040:

- Bug: Radio state drifted from the store after clearFilters() or
  handlePopState() because the radios had no reactive checked binding
  — the change event only fires on user-initiated transitions, so any
  state mutation through other paths left the DOM stale. Fix:
  - Add per-<li> data-wp-context with the option's value so the new
    callbacks.isStaticFilterSelected (landed in the foundation PR
    #49038) can compare it against state.staticFilterSelections.
  - Add data-wp-bind--checked="callbacks.isStaticFilterSelected" to
    each radio input. Server-rendered `checked` still seeds first
    paint; the reactive binding takes over after IA hydration.
- Doc nit (copilot): FILTER_CHECKBOX_VARIATION_ICONS docblock count
  was '19 + 8'; updated to '20 + 8' to reflect the new filter-static
  entry in BLOCK_ICONS.

Also deferred: the 're-pick a checked radio to deselect' line in the
prior testing instructions is unreachable (browser change event
semantics) — removing it from the PR body in a follow-up edit instead
of carrying a separate code change.

<!-- jp-loop -->
…9038)

Two non-blocker nits from claude[bot]'s re-review:

- afterEach in the 'store callbacks' describe block now resets
  state.staticFilterSelections to {} so the assignment at the end of
  the isStaticFilterSelected test doesn't leak into later cases in the
  same block. Currently benign (no downstream reader), but defensive.
- Add a test pinning the ?. guard on state.staticFilterSelections: when
  the slice is undefined (PHP seed hasn't populated it yet, e.g. on a
  page with no filter-static block), the callback returns false instead
  of crashing.

<!-- jp-loop -->
kangzj added a commit that referenced this pull request May 21, 2026
…nt (PR #49040)

Two findings from claude[bot]'s re-review on PR #49040:

- Bug: Radio state drifted from the store after clearFilters() or
  handlePopState() because the radios had no reactive checked binding
  — the change event only fires on user-initiated transitions, so any
  state mutation through other paths left the DOM stale. Fix:
  - Add per-<li> data-wp-context with the option's value so the new
    callbacks.isStaticFilterSelected (landed in the foundation PR
    #49038) can compare it against state.staticFilterSelections.
  - Add data-wp-bind--checked="callbacks.isStaticFilterSelected" to
    each radio input. Server-rendered `checked` still seeds first
    paint; the reactive binding takes over after IA hydration.
- Doc nit (copilot): FILTER_CHECKBOX_VARIATION_ICONS docblock count
  was '19 + 8'; updated to '20 + 8' to reflect the new filter-static
  entry in BLOCK_ICONS.

Also deferred: the 're-pick a checked radio to deselect' line in the
prior testing instructions is unreachable (browser change event
semantics) — removing it from the PR body in a follow-up edit instead
of carrying a separate code change.

<!-- jp-loop -->
)

Pre-existing 'Code coverage requirement' check flagged that the threading
paths through hasActiveFilters / activeFilterCount / syncToUrl weren't
exercised end-to-end. Added:

- hasActiveFilters + activeFilterCount include staticFilterSelections —
  counts entries with non-empty values, treats empty-string ('cleared')
  entries as inactive.
- syncToUrl writes static-filter selections as scalar params alongside
  other state — regression guard against a future change forgetting to
  thread the slice through pushStateToUrl.
- beforeEach now resets state.staticFilterSelections so subsequent
  tests don't inherit leftover values from earlier ones (the new
  syncToUrl test would otherwise leak its assignment forward).

<!-- jp-loop -->
kangzj added a commit that referenced this pull request May 21, 2026
…nt (PR #49040)

Two findings from claude[bot]'s re-review on PR #49040:

- Bug: Radio state drifted from the store after clearFilters() or
  handlePopState() because the radios had no reactive checked binding
  — the change event only fires on user-initiated transitions, so any
  state mutation through other paths left the DOM stale. Fix:
  - Add per-<li> data-wp-context with the option's value so the new
    callbacks.isStaticFilterSelected (landed in the foundation PR
    #49038) can compare it against state.staticFilterSelections.
  - Add data-wp-bind--checked="callbacks.isStaticFilterSelected" to
    each radio input. Server-rendered `checked` still seeds first
    paint; the reactive binding takes over after IA hydration.
- Doc nit (copilot): FILTER_CHECKBOX_VARIATION_ICONS docblock count
  was '19 + 8'; updated to '20 + 8' to reflect the new filter-static
  entry in BLOCK_ICONS.

Also deferred: the 're-pick a checked radio to deselect' line in the
prior testing instructions is unreachable (browser change event
semantics) — removing it from the PR body in a follow-up edit instead
of carrying a separate code change.

<!-- jp-loop -->
@kangzj

This comment has been minimized.

@kangzj kangzj removed the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label May 21, 2026
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 21, 2026

🤖 Review-cycle summary — a452d0faf8294993299b

5 round(s); 59 functional CI checks pass; 1 informational coverage check failing (non-blocking, documented above); 5 review items addressed; 2 AI reviewers approved.

What changed during the cycle

Commits added on top of the initial PR:

  • dbca1bf6c0 — Address review: orphaned setFilter JSDoc, RESERVED_PARAMS guard, gateStaticFilterSelections return shape.
  • 7b0731e9d9 — Add isStaticFilterSelected callback for reactive radio binding (surfaced by PR Search: jetpack-search/filter-static block (SEARCH-219, 3/3) #49040 review).
  • 64f937627d — Address re-review nits: afterEach reset + undefined-slice test.
  • 294993299b — Add integration coverage for staticFilterSelections threading (hasActiveFilters / activeFilterCount / syncToUrl tests).

Review items addressed:

Source Item Resolution
claude[bot] Orphaned setFilter JSDoc Moved back above setFilter (dbca1bf6c0).
claude[bot] Missing RESERVED_PARAMS guard in stateToUrlParams Added symmetric guard with urlParamsToState (dbca1bf6c0).
claude[bot] gateStaticFilterSelections return shape asymmetry with gateActiveFilters Now returns { gated, droppedAny } (dbca1bf6c0).
claude[bot] (PR #49040 surfacing) Radio state drift after clearFilters() / handlePopState() — needed reactive binding Added callbacks.isStaticFilterSelected (7b0731e9d9).
claude[bot] Test afterEach didn't reset staticFilterSelections + missing undefined-slice test Reset in afterEach + new test pinning the ?. guard (64f937627d).

Test count: 540 → 554 (+14) JS tests passing.

CI: 59 functional checks pass. The one failing check — Code coverage requirement — is an informational threshold; integration tests added in 294993299b lifted coverage of the threading paths, but the line-coverage gate still flags. Per the review-cycle contract, non-security informational failures don't block the clean transition.

Reviewer outcome:

  • claude[bot] (round 3 + 4 re-reviews): "Good to merge from a review standpoint."
  • copilot-swe-agent: "I don't see new blocking correctness/security issues."

@kangzj kangzj self-assigned this May 22, 2026
@kangzj kangzj merged commit 8199e45 into trunk May 22, 2026
74 of 75 checks passed
@kangzj kangzj deleted the search-219-static-filter-store-foundation branch May 22, 2026 00:58
@github-actions github-actions Bot removed the [Status] Needs Team Review Obsolete. Use Needs Review instead. label May 22, 2026
kangzj added a commit that referenced this pull request May 22, 2026
…nt (PR #49040)

Two findings from claude[bot]'s re-review on PR #49040:

- Bug: Radio state drifted from the store after clearFilters() or
  handlePopState() because the radios had no reactive checked binding
  — the change event only fires on user-initiated transitions, so any
  state mutation through other paths left the DOM stale. Fix:
  - Add per-<li> data-wp-context with the option's value so the new
    callbacks.isStaticFilterSelected (landed in the foundation PR
    #49038) can compare it against state.staticFilterSelections.
  - Add data-wp-bind--checked="callbacks.isStaticFilterSelected" to
    each radio input. Server-rendered `checked` still seeds first
    paint; the reactive binding takes over after IA hydration.
- Doc nit (copilot): FILTER_CHECKBOX_VARIATION_ICONS docblock count
  was '19 + 8'; updated to '20 + 8' to reflect the new filter-static
  entry in BLOCK_ICONS.

Also deferred: the 're-pick a checked radio to deselect' line in the
prior testing instructions is unreachable (browser change event
semantics) — removing it from the PR body in a follow-up edit instead
of carrying a separate code change.

<!-- jp-loop -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Search Contains core Search functionality for Jetpack and Search plugins [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants