You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The EP catalog-search Plasmic components currently cover the seven InstantSearch primitives most search pages use end-to-end: EPCatalogSearchProvider, EPSearchBox, EPSearchHits, EPSearchStats, EPSearchSortBy, EPSearchPagination, EPRefinementList, EPHierarchicalMenu, EPRangeFilter (all in plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/).
That's a solid foundation — but there are concrete InstantSearch hooks and UX patterns we don't expose to designers yet. This issue catalogs those gaps so we can prioritise which to ship next without losing the rest.
Each gap maps to a react-instantsearch hook (no client-side reinvention needed); pattern follows the existing components — wrap the hook, publish a DataProvider named context (or per-item context for repeaters), expose ref actions where useful.
Lessons from shipped work (informs remaining items)
These supersede the original "Pattern to follow" section. They reflect what actually worked once #316 / #309 / #312 / #314 / #329 hit production.
L1. Wrapper-onClick beats registered ref-actions for fresh-drop UX
PR #316's most consequential design choice: EPClearRefinements and EPCurrentRefinements both put onClick={refine} on the wrapper <div> (or per-repeated child) rather than on a Plasmic ref-action the designer must wire. PR #312 (Pagination) and PR #314 (SortBy) had to leave wiring as a Studio step because of MCP gap #81 / PRD #310.
Apply when: action is a single zero-arg call (refine, clear, loadMore, nextPage, setHitsPerPage(value) per-item). Reserve ref-actions for: behaviour invoked from outside the component's slot (e.g. an external "reset" button calling clear()), or any callable that takes designer-supplied arguments at runtime. Hits:#5, #6, #7, #8.
L2. Components that own visible DOM lose designer styles (the #308 finding)
TPL_COMPONENT_PROPS filters appearance styles on code-component instances. The fix established by #309 was to make EPSearchBox provider-only and push chrome (<input>, clear button) into the slot as Plasmic-controlled tags. EPSearchHits's grid-owning style is now the only documented exception, and it's there because Plasmic strips display: grid className-side.
Apply when: a component would render any visible chrome the designer might style. Hits:#3, #4, #6, #8 — none should render their own <select>/<dropdown>/pill chrome. Use provider + (per-item) context + designer-composed slot. Open decision for #5 InfiniteHits: clone EPSearchHits' grid-owning exception, or push layout into the slot? Resolve in the PR description, don't punt.
L3. Default slots must ship a recognizable, working shape
PR #312 was specifically a corrective for text("Page 1 of 4") placeholder — defaultValue now renders an hbox{Prev | text | Next} shape. PR #316 followed the rule (chip-shaped hbox with pill border-radius for EPCurrentRefinements; button("Clear all") for EPClearRefinements). PR #329 followed the rule for EPSearchEmpty (h2 + p in a centered vbox).
Apply to: every remaining gap. Ship a defaultValue that renders a working, recognizable shape on fresh drop. No empty slots, no placeholder text. Container types must include "children": []; long-form CSS only in node.add defaults (borderTopLeftRadius not borderRadius, paddingTop etc.).
L4. Ergonomic prop shape, raw shape preserved as escape hatch
PR #314 replaced value: "search/sort/price.USD.float_price:asc" with {field, direction, label} and kept the raw {value, label} as an advanced escape hatch. Designers no longer need to know URL-composition rules to get a working sort.
L5. Deprecated props become hidden no-ops, never deleted
EPSearchBox kept placeholder/autoFocus/showClear as hidden: () => true props because removing registered props leaves stale FunctionArg/StateParam refs in the bundle and MCP writes start failing.
Hits #10 directly: when EPCatalogSearchProvider.hitsPerPage flips from runtime source-of-truth to initial value, do not remove the prop. Keep it registered, change its description to spell out the semantics shift, and let runtime ignore it once an EPHitsPerPage is mounted on the page. Document the flip in the PR body.
L6. Don't pre-extract abstractions for the second usage
PR #316 extracted cloneWithInjectedHandlers as a deep module with 5 unit tests — and then neither component that motivated the PR uses it. The wrapper-onClick path won. The helper sits in the codebase awaiting a future "ad-hoc React children" usage that hasn't materialized.
Apply to: start each remaining component with the wrapper / per-item-onClick pattern. Extract a handler-injection helper only when a real second usage forces it. Stop framing Pattern C as canonical when the shipping code is Pattern A.
L7. previewState: "auto" | "withData" + Mock sibling is non-negotiable
Every shipped component repeats this pattern: a MockX component sibling with the same forwardRef shape, gated by previewState === "withData" || (previewState === "auto" && inEditor). Mocks live in design-time-data.ts. PR #329 (EPSearchEmpty) is the canonical case — the prod render path returns null when nbHits > 0, so the Mock + Inner split is the only design-time render path. The visibility gate also tightened from the original spec: the live branch uses useInstantSearch().results !== null && results.nbHits === 0 (not useStats().nbHits) so the slot stays silent during the SSR-to-first-response window and during search-time errors.
L8. Live-verify on examples/ep-commerce-app-router every PR
PR #314 caught broken sort defaults this way (the seemingly-correct value strings 400'd against the real EP catalog). PR #309 caught designer styles not reaching the live DOM. PR #316 verified the chip-dismiss and auto-hide behaviour end-to-end.
Apply to: every remaining gap-closing PR. List the verification matrix in the PR body, including negative cases (no chips when 0 refinements, hidden when 0 hits, etc.).
Hook:useAutocomplete (or composed with virtual indexes)
Today's behaviour: the search box only fires on blur/debounce; no dropdown of matching products / queries appears as the user types.
Shape: likely a sibling-of-EPSearchBox repeater publishing $ctx.currentSuggestion. Could either be its own component or a render-prop / slot extension on EPSearchBox.
Open design question: does this use the same indexName: "search" or a separate "autocomplete" index? Adapter currently allows both; need to confirm with the catalog-search team what shape is recommended.
Applied lessons: L2 (provider + repeater, no dropdown chrome owned by the component), L3 (default slot ships a suggestion-row shape), L4 (indexName defaults to parent's "search"), L8 (verify against real catalog).
4. EPHitsPerPage — results-per-page selector
Hook:useHitsPerPage
Today's behaviour:hitsPerPage is a hardcoded prop on EPCatalogSearchProvider (default 12); shoppers cannot switch 24/48/96.
Shape: repeater of options. Per-item context publishes {value, label, isRefined, refine}. Designer composes the visible control (radio group, pills, custom dropdown) in the slot.
Coupling: when this lands, the Provider's hitsPerPage prop becomes the initial value, not the source of truth at runtime. Worth documenting the flip — see Deploy integration merge main #10.
Applied lessons: L1 (per-item onClick → refine), L2 (no <select> rendered by the component), L3 (default slot is a styled radio/pill row), L4 (items shape {value, label} with [12, 24, 48] default), L5 (Provider prop kept, semantics shift), L8.
5. EPInfiniteHits — load-more / infinite scroll
Hook:useInfiniteHits
Today's behaviour: only paginated discrete-page lists; mobile-heavy storefronts usually want scroll.
Shape: repeater (same per-item currentProduct shape as EPSearchHits for layout reuse) + a separate "load more" sibling or trailing-slot loadMore() action.
Decision needed: ship as a separate component, or as a previewState-style mode on EPSearchHits? Separate is cleaner; the component name is part of the discoverability story in the Plasmic insert menu.
Applied lessons: L1 (loadMore wired via wrapper-onClick on a "Load more" slot child; visibility driven by isLastPage matching the EPClearRefinements canRefine pattern), L2 + open call: clone EPSearchHits' grid-owning exception or push layout to the slot — resolve in the PR body, don't punt, L3 (default slot ships a recognisable "Load more" button below repeated hits), L8.
6. EPMenu — single-select dropdown facet
Hook:useMenu
Today's behaviour:EPRefinementList is multi-select; EPHierarchicalMenu is a tree. No primitive for "pick exactly one Status / one Material from a list".
Shape: repeater (per-item: {value, label, count, isRefined, refine}). Don't ship a <select> element — the component is provider-only, designer composes the dropdown chrome in the slot.
Applied lessons: L1 (per-item onClick → refine), L2 (no native <select> owned by the component), L3 (default slot ships a list-of-options shape), L8.
7. EPToggleRefinement — boolean filter switch ← NEXT UP
Hook:useToggleRefinement
Today's behaviour: common flags ("In stock only", "On sale") have to be modelled as a single-value EPRefinementList, which renders as a list of one.
Shape: single-slot, publishes $ctx.toggleData = { isRefined, refine }. Designer wires a switch / checkbox / pill in the slot.
Applied lessons: L1 (wrapper-onClick → refine), L2 (no chrome rendered), L3 (default slot ships a labelled toggle/checkbox shape), L4 ({attribute, on} props pass straight through to the hook), L7 (Mock + Inner split — Mock renders default slot in canvas; Inner gates on hook output at runtime), L8.
8. EPNumericMenu — preset numeric ranges
Hook:useNumericMenu
Today's behaviour:EPRangeFilter is slider-only — no "Under $25 / $25–$50 / $50+" pill set.
Shape: repeater, per-item context with { label, isRefined, refine }.
Applied lessons: L1 (per-item onClick → refine), L2 (no pill chrome rendered by component), L3 (default slot ships a recognisable pill row), L4 (items prop accepts {label, from, to} ergonomic shape composing into the hook's raw {label, start, end}), L8.
Non-component gaps
9. ✅ EPSearchEmpty — no-results state — SHIPPED (PR #329)
Hook:useInstantSearch (visibility gate) + useStats (context for slot copy)
Outcome: Single-slot, <div data-ep-search-empty role="status"> wrapper. Visibility gate is useInstantSearch().results !== null && results.nbHits === 0 — stays silent during SSR-to-first-response and during search-time errors (errors remain Provider's errorContent concern). Mock + Inner split per L7 — editor branch renders the wrapper unconditionally without ever calling useInstantSearch. Default slot ships heading + body ("No results found" / "Try clearing your filters…") in a centered vbox. Headless :where() block applies width: 100%; align-self: stretch; text-align: center.
10. hitsPerPage is a fixed Provider prop
Coupled with feat: use elastic path domain for canvas hosting #4 above. Right now EPCatalogSearchProvider.hitsPerPage flows directly into the InstantSearch <Configure> block. If EPHitsPerPage ships as a sibling, that prop needs to become an initial value, not the live source of truth. Worth tracking explicitly so the change is intentional, not a side-effect of building feat: use elastic path domain for canvas hosting #4.
Applied lessons: L5 — keep the prop registered, do not delete. Update its description to document the semantics flip. Runtime ignores it once an EPHitsPerPage is mounted on the page. Document the flip in the PR body so consumers aren't surprised.
Out of scope (deferred)
These InstantSearch hooks exist but are either niche or belong to a recommendation domain rather than core search. Not blocking; revisit if a customer asks:
build: deploy workflows #5 EPInfiniteHits — moderate complexity; resolve the L2 "own grid vs. slot layout" decision in the PR body before code.
feat: add init ep hosting endpoints #3 EPSearchAutocomplete — biggest scope; needs catalog-search-team alignment on indexName strategy first (L4 default vs. dedicated index).
Pattern to follow
Superseded by "Lessons from shipped work" above as of 2026-05-07. The original 7-point pattern checklist is preserved in the issue history; the lessons section reflects what actually worked once #316 / #309 / #312 / #314 / #329 hit production.
Context
The EP catalog-search Plasmic components currently cover the seven InstantSearch primitives most search pages use end-to-end:
EPCatalogSearchProvider,EPSearchBox,EPSearchHits,EPSearchStats,EPSearchSortBy,EPSearchPagination,EPRefinementList,EPHierarchicalMenu,EPRangeFilter(all inplasmicpkgs/commerce-providers/elastic-path/src/catalog-search/).That's a solid foundation — but there are concrete InstantSearch hooks and UX patterns we don't expose to designers yet. This issue catalogs those gaps so we can prioritise which to ship next without losing the rest.
Each gap maps to a
react-instantsearchhook (no client-side reinvention needed); pattern follows the existing components — wrap the hook, publish aDataProvidernamed context (or per-item context for repeaters), expose ref actions where useful.Status (2026-05-21)
hitsPerPagesemantics flipinvokeRefActionwiring from MCP. All "wire onChange/onClick → refAction" steps remain Studio-UI only until PRD: Expose Plasmic'sinvokeRefActioninteraction type in the MCP (gap #81) #310 lands. Lessons below assume this and prefer pre-wired interactions where feasible.Lessons from shipped work (informs remaining items)
These supersede the original "Pattern to follow" section. They reflect what actually worked once #316 / #309 / #312 / #314 / #329 hit production.
L1. Wrapper-onClick beats registered ref-actions for fresh-drop UX
PR #316's most consequential design choice:
EPClearRefinementsandEPCurrentRefinementsboth putonClick={refine}on the wrapper<div>(or per-repeated child) rather than on a Plasmic ref-action the designer must wire. PR #312 (Pagination) and PR #314 (SortBy) had to leave wiring as a Studio step because of MCP gap #81 / PRD #310.Apply when: action is a single zero-arg call (
refine,clear,loadMore,nextPage,setHitsPerPage(value)per-item).Reserve ref-actions for: behaviour invoked from outside the component's slot (e.g. an external "reset" button calling
clear()), or any callable that takes designer-supplied arguments at runtime.Hits: #5, #6, #7, #8.
L2. Components that own visible DOM lose designer styles (the #308 finding)
TPL_COMPONENT_PROPSfilters appearance styles on code-component instances. The fix established by #309 was to makeEPSearchBoxprovider-only and push chrome (<input>, clear button) into the slot as Plasmic-controlled tags.EPSearchHits's grid-owning style is now the only documented exception, and it's there because Plasmic stripsdisplay: gridclassName-side.Apply when: a component would render any visible chrome the designer might style.
Hits: #3, #4, #6, #8 — none should render their own
<select>/<dropdown>/pill chrome. Use provider + (per-item) context + designer-composed slot.Open decision for #5 InfiniteHits: clone EPSearchHits' grid-owning exception, or push layout into the slot? Resolve in the PR description, don't punt.
L3. Default slots must ship a recognizable, working shape
PR #312 was specifically a corrective for
text("Page 1 of 4")placeholder —defaultValuenow renders anhbox{Prev | text | Next}shape. PR #316 followed the rule (chip-shaped hbox with pill border-radius forEPCurrentRefinements;button("Clear all")forEPClearRefinements). PR #329 followed the rule forEPSearchEmpty(h2 + p in a centered vbox).Apply to: every remaining gap. Ship a
defaultValuethat renders a working, recognizable shape on fresh drop. No empty slots, no placeholder text. Container types must include"children": []; long-form CSS only innode.adddefaults (borderTopLeftRadiusnotborderRadius,paddingTopetc.).L4. Ergonomic prop shape, raw shape preserved as escape hatch
PR #314 replaced
value: "search/sort/price.USD.float_price:asc"with{field, direction, label}and kept the raw{value, label}as an advanced escape hatch. Designers no longer need to know URL-composition rules to get a working sort.Hits:
{label: "Under $25", to: 25}style, not raw{start, end, label, value, isRefined}.{value, label}plus a sensible default (e.g.[12, 24, 48]) baked in.indexName— mirror SortBy: default to parent"search", override per-deployment.{attribute, on}shape mirrors hook input directly; safe to pass through.L5. Deprecated props become hidden no-ops, never deleted
EPSearchBox kept
placeholder/autoFocus/showClearashidden: () => trueprops because removing registered props leaves staleFunctionArg/StateParamrefs in the bundle and MCP writes start failing.Hits #10 directly: when
EPCatalogSearchProvider.hitsPerPageflips from runtime source-of-truth to initial value, do not remove the prop. Keep it registered, change its description to spell out the semantics shift, and let runtime ignore it once anEPHitsPerPageis mounted on the page. Document the flip in the PR body.L6. Don't pre-extract abstractions for the second usage
PR #316 extracted
cloneWithInjectedHandlersas a deep module with 5 unit tests — and then neither component that motivated the PR uses it. The wrapper-onClick path won. The helper sits in the codebase awaiting a future "ad-hoc React children" usage that hasn't materialized.Apply to: start each remaining component with the wrapper / per-item-onClick pattern. Extract a handler-injection helper only when a real second usage forces it. Stop framing Pattern C as canonical when the shipping code is Pattern A.
L7.
previewState: "auto" | "withData"+ Mock sibling is non-negotiableEvery shipped component repeats this pattern: a
MockXcomponent sibling with the same forwardRef shape, gated bypreviewState === "withData" || (previewState === "auto" && inEditor). Mocks live indesign-time-data.ts. PR #329 (EPSearchEmpty) is the canonical case — the prod render path returnsnullwhennbHits > 0, so the Mock + Inner split is the only design-time render path. The visibility gate also tightened from the original spec: the live branch usesuseInstantSearch().results !== null && results.nbHits === 0(notuseStats().nbHits) so the slot stays silent during the SSR-to-first-response window and during search-time errors.L8. Live-verify on
examples/ep-commerce-app-routerevery PRPR #314 caught broken sort defaults this way (the seemingly-correct
valuestrings 400'd against the real EP catalog). PR #309 caught designer styles not reaching the live DOM. PR #316 verified the chip-dismiss and auto-hide behaviour end-to-end.Apply to: every remaining gap-closing PR. List the verification matrix in the PR body, including negative cases (no chips when 0 refinements, hidden when 0 hits, etc.).
Component gaps — high signal
1. ✅
EPClearRefinements— clear-all-filters button — SHIPPED (PR #316)useClearRefinementscanRefine === falseshort-circuit. Ref-actionclear()exposed as escape hatch.2. ✅
EPCurrentRefinements— active-filter chips / breadcrumb — SHIPPED (PR #316)useCurrentRefinements$ctx.currentRefinementChipcarries{attribute, attributeLabel, type, value, label, operator, count, refine}. Returns null when no refinements active.3.
EPSearchAutocomplete— search-as-you-type suggestionsuseAutocomplete(or composed with virtual indexes)EPSearchBoxrepeater publishing$ctx.currentSuggestion. Could either be its own component or a render-prop / slot extension onEPSearchBox.indexName: "search"or a separate"autocomplete"index? Adapter currently allows both; need to confirm with the catalog-search team what shape is recommended.indexNamedefaults to parent's"search"), L8 (verify against real catalog).4.
EPHitsPerPage— results-per-page selectoruseHitsPerPagehitsPerPageis a hardcoded prop onEPCatalogSearchProvider(default 12); shoppers cannot switch 24/48/96.{value, label, isRefined, refine}. Designer composes the visible control (radio group, pills, custom dropdown) in the slot.hitsPerPageprop becomes the initial value, not the source of truth at runtime. Worth documenting the flip — see Deploy integration merge main #10.refine), L2 (no<select>rendered by the component), L3 (default slot is a styled radio/pill row), L4 (items shape{value, label}with[12, 24, 48]default), L5 (Provider prop kept, semantics shift), L8.5.
EPInfiniteHits— load-more / infinite scrolluseInfiniteHitscurrentProductshape asEPSearchHitsfor layout reuse) + a separate "load more" sibling or trailing-slotloadMore()action.previewState-style mode onEPSearchHits? Separate is cleaner; the component name is part of the discoverability story in the Plasmic insert menu.loadMorewired via wrapper-onClick on a "Load more" slot child; visibility driven byisLastPagematching the EPClearRefinementscanRefinepattern), L2 + open call: cloneEPSearchHits' grid-owning exception or push layout to the slot — resolve in the PR body, don't punt, L3 (default slot ships a recognisable "Load more" button below repeated hits), L8.6.
EPMenu— single-select dropdown facetuseMenuEPRefinementListis multi-select;EPHierarchicalMenuis a tree. No primitive for "pick exactly one Status / one Material from a list".{value, label, count, isRefined, refine}). Don't ship a<select>element — the component is provider-only, designer composes the dropdown chrome in the slot.refine), L2 (no native<select>owned by the component), L3 (default slot ships a list-of-options shape), L8.7.
EPToggleRefinement— boolean filter switch ← NEXT UPuseToggleRefinementEPRefinementList, which renders as a list of one.$ctx.toggleData = { isRefined, refine }. Designer wires a switch / checkbox / pill in the slot.refine), L2 (no chrome rendered), L3 (default slot ships a labelled toggle/checkbox shape), L4 ({attribute, on}props pass straight through to the hook), L7 (Mock + Inner split — Mock renders default slot in canvas; Inner gates on hook output at runtime), L8.8.
EPNumericMenu— preset numeric rangesuseNumericMenuEPRangeFilteris slider-only — no "Under $25 / $25–$50 / $50+" pill set.{ label, isRefined, refine }.refine), L2 (no pill chrome rendered by component), L3 (default slot ships a recognisable pill row), L4 (items prop accepts{label, from, to}ergonomic shape composing into the hook's raw{label, start, end}), L8.Non-component gaps
9. ✅
EPSearchEmpty— no-results state — SHIPPED (PR #329)useInstantSearch(visibility gate) +useStats(context for slot copy)<div data-ep-search-empty role="status">wrapper. Visibility gate isuseInstantSearch().results !== null && results.nbHits === 0— stays silent during SSR-to-first-response and during search-time errors (errors remain Provider'serrorContentconcern). Mock + Inner split per L7 — editor branch renders the wrapper unconditionally without ever callinguseInstantSearch. Default slot ships heading + body ("No results found" / "Try clearing your filters…") in a centered vbox. Headless:where()block applieswidth: 100%; align-self: stretch; text-align: center.10.
hitsPerPageis a fixed Provider propEPCatalogSearchProvider.hitsPerPageflows directly into the InstantSearch<Configure>block. IfEPHitsPerPageships as a sibling, that prop needs to become an initial value, not the live source of truth. Worth tracking explicitly so the change is intentional, not a side-effect of building feat: use elastic path domain for canvas hosting #4.EPHitsPerPageis mounted on the page. Document the flip in the PR body so consumers aren't surprised.Out of scope (deferred)
These InstantSearch hooks exist but are either niche or belong to a recommendation domain rather than core search. Not blocking; revisit if a customer asks:
usePoweredBy— "Powered by" attribution.useVoiceSearch— voice input.useQueryRules— rules engine (contextual ranking, pinned results).useRelatedProducts— related/similar products.useFrequentlyBoughtTogether— bundle recommendations.Suggested sequencing
Updated based on lessons applied above and the cross-cutting #310 blocker:
indexNamestrategy first (L4 default vs. dedicated index).Pattern to follow
Superseded by "Lessons from shipped work" above as of 2026-05-07. The original 7-point pattern checklist is preserved in the issue history; the lessons section reflects what actually worked once #316 / #309 / #312 / #314 / #329 hit production.