Make catalog browsing store-driven: native facets, server-side sort and cursor paging - #79
Merged
Merged
Conversation
/shop spelled its own param parsing, category list and href builder. Moving them behind a single module gives the collection route the same validated query semantics instead of a second convention, and adds match counts and a facet that is omitted when it cannot change the result.
Both adapters run the same normalized narrowing listProducts already uses, so a collection filtered live cannot drift from one filtered against fixtures.
Product count, a no-JavaScript sort form that carries every param it does not own, and the mobile disclosure that is the only way to reach the facets below the sidebar's breakpoint.
The results row that holds the facet sidebar beside the grid. Which side the sidebar sits on is the order the merchant put the children in, not a setting.
Every facet row is an href the route already validated, so narrowing a collection needs no JavaScript and every result is a shareable URL.
Paged results with an authored page size, plus the empty state that clears only the facet params so anything else on the URL survives the reset.
The shell owns layout and nothing else, and renders the default composition when it has no children so a collection URL never loses its grid. collection-grid is retired; mc--product-grid supersedes it.
The collection route parsed no query state at all, so nothing on the page held a filter. It now validates category, activity and sort against the collection's own products, reads the narrowed result through the storefront seam, and hands it down already resolved — a section never parses a param. Closes #78
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
14 tasks
Activity and Category were two near-identical twenty-line blocks differing only in param name and label, and the category strings were spelled three times over. One record now carries the display order, the labels and the set a query param is validated against, and one dimension table builds both groups. Internals that had no caller outside the module stop being exported.
Counts were gated three times: an option on the facet builder, an undefined check on the link, and a prop on the sidebar. The builder always counts now — it is a pass over an array the page already holds — and the sidebar's prop is the single switch. It defaults off, so /shop keeps its current look and mc--filters opts in through its own setting.
The grid was three components and a Suspense boundary. The boundary guarded a static-render bailout that cannot happen on a route already reading searchParams on the server, and its fallback reached useSearchParams through the pagination it rendered, so it could never have been a non-suspending fallback anyway. Also drops emptyHeading, which named a link label rather than a heading and was a second free-text setting on an empty state.
Same bailout that cannot happen; the hooks move up into the toolbar and the form takes what it needs as props.
mapProduct consulted a per-handle profile table for category, activities, subtitle, repair copy, related handles, colorway ids and swatch colours, and rejected any handle absent from a nine-item allowlist. None of that could run on another store. Category is now productType, activities are the product's own tags minus ownership and ops bookkeeping, the subtitle is its first forward.highlights entry, colorway ids are derived from the published Color values, and swatch colours are Shopify's own — null when the merchant set none, which is the common case, so selectors fall back to the colorway image. Related products are the store's other items of the same type. ProductCategory widens from a three-literal union to the merchant's own label. The canonical assertions the profiles supplied go with them: approved Color order, an exact variant count, canonical variant order and a required size list are all the merchant's business. Structural integrity stays: one Color option, no duplicate values, a media map covering exactly the published colours, no unreferenced media, USD money, unique merchandise ids.
The static fixtures were generated from the same profiles the mapper used, so deleting the table left static mode with no catalog. They are literal data now, mirroring the connected store's product types, tags and option values, and they run the same derivations the live mapper does — so the two modes cannot drift. catalog-presentation.ts is deleted.
The navigation mapper checked every collection against a theme-side profile: the exact four approved handles, their titles, and their product membership in order. A store with different collections failed the read. Title, image and membership now come from Shopify, and the query asks for the description and a forward.field_code metafield too. Both are optional — a store that sets neither renders the collection without them rather than borrowing copy the theme invented, so heroImage is nullable and the hero and index omit what is missing. A truncated collections page is still a hard failure; a store simply having fewer collections is not.
Every suite that asserted the approved-handle list, canonical colour order, canonical variant order or the profile table now asserts what has to hold for any store: unique slug-shaped colorway ids, a product type and tags present, a swatch only when the store published one, related products sharing a type, and a media map that covers exactly the published colours. The catalog fixture carries the connected store's real per-product tags.
A facet value carries Shopify's own ProductFilter JSON as an opaque string: the theme never builds or reads it, so a filter a merchant enables later works without a code change. Sort options become real sort keys — TITLE, PRICE, BEST_SELLING, CREATED — rather than an ordering the theme applies afterwards, and featured is the merchant's own collection order. Without credentials there is no API to ask, so the deterministic catalog synthesizes the two facets every Shopify store exposes by default and applies them locally, matching ids, inputs and counts. An unrecognised filter shape leaves the catalog alone rather than emptying it.
The Storefront API accepts no filters argument outside a collection, so the catalog level is sort and paging only — which is exactly why Pilot's own all-products route has no filters either. Both modes agree on that rather than offering controls that could not be applied.
Shop was a hand-written page with theme-invented Category and Activity filters. It is now the all-products tree — a toolbar and a grid — over the page the store returned, and it renders that composition itself when the project has no ALL_PRODUCTS template, so the route is never empty. The invented facet module, its sidebar and the old results column go with it.
The suites assert what has to hold for any store: a facet's input round-trips through the URL untouched, an applied value is marked and clearing it is reachable, the order control carries everything it does not own but drops the cursor, and paging is cursors rather than page numbers.
6 tasks
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #78
Closes #80
Why
Two problems, one root cause.
/shop/<collectionHandle>parsed no query state at all, so nothing on a collection page held a filter. The filters/shopdid have (Category and Activity) existed in no Shopify store: they were constants in a per-handle profile table the theme carried. Connecting Pilot to the same store showed what the store actually offers: Availability and Price, and nothing else.That table was one of several. Menus had to match a hardcoded tree, the header's Shop panel had four fixed cards, pages and articles came from an allowlist with their presentation in a profile table, and whenever live data did not fit, the theme quietly served fixtures instead. So the theme could not run on another store.
What changed
The catalog is the store's.
catalog-presentation.tsand the handle allowlists are deleted.categoryisproductType,activitiesare the product's tags minus ownership and ops bookkeeping, the subtitle is its firstforward.highlightsentry, colorway ids derive from the published Color values, swatches are Shopify's native ones, and related products are the store's other items of the same type. Collections take their title, image and membership from Shopify, with an optional description and aforward.field_codemetafield.Facets are the Storefront API's. A collection is its own query with
filters,sortKey,reverseand cursors, and it returnsproducts.filters. A facet value'sinputis opaque Shopify JSON. It travels into the URL under the facet's own id and back into the query untouched, so a filter a merchant enables in Search & Discovery renders with no code change. Without credentials, the deterministic catalog synthesizes the same two default facets and ignores shapes it does not recognise, so both modes answer a route the same way.Sort is Shopify's. Six options, each a real sort key rather than an ordering applied after the fact.
Menus, footer and the Shop panel are the store's. Menus keep whatever the merchant arranged: order, labels and size. Shopify paths map onto theme routes (
/collections/x→/shop/x,/collections/all→/shop, any blog →/journal), and one shared mapper (shopify/theme-routes.ts) serves both menus and content links. A menu the store has not set up is empty, and a link to another origin or to a route the theme lacks is left out. The header's Shop panel is built from the merchant's Shop links, and each row takes its description, image and field code from its collection.Content is the store's. Every page and every article across blogs is read, instead of seven aliased pages and one approved blog.
content-presentation.tsis deleted. An article's plate counts from the oldest, its reading time comes from its word count, its image is its own, and location/coordinates are the optionalforward.location/forward.coordinatesmetafields. A page, article or policy whose body the parser refuses is left out (its route 404s) instead of failing the whole read. The parser still refuses the markup.No fallbacks. Shopify mode never reads fixtures: navigation, footer, collections and content fail closed, and every executor is required. A Weaverse page renders exactly as authored, so an empty template renders empty and a route with no page answers 404. No route renders its own copy of a section a template omits (
/shop, PDP), a childlessmain-productrenders only its shell, and anmp--*child outside it renders nothing.Composition.
Query state stays theme-owned: the route validates the facet params, sort and cursor, reads one page, and hands the result down as
browse. A section never parses a param, so reordering the tree cannot change which products a URL selects.Both trees render through the same
CatalogToolbarandCatalogGridcomponents, so eachap--*/mc--*section is a thin Weaverse wrapper that differs only in its data and empty state. Sort options are one table typed with Hydrogen's ownProductCollectionSortKeys/ProductSortKeys, and both paged reads share one executor.Two API facts worth knowing
QueryRoot.productsaccepts nofiltersargument, onlyfirst,after,last,before,reverse,sortKeyandquery. Faceted browsing is a collection feature, so/shopis sort and paging only.TITLEexists in both sort key enums, soname(A–Z) is a real server-side sort.Breaking
?colorway=values changed (charcoal→charcoal-moss). Old links still resolve to the product and fall back to its first colorway./collections/forward, which is now/shop/forward. Point them at/collections/allto reach/shop./shoprenders only what itsALL_PRODUCTStemplate composes. Until that template has anall-productssection, the page is blank.sortinto/shop/**, because facets belong to one collection.repairis empty until it becomes a theme setting, because the store has no source for it.data-sharing-opt-outpage 404s, since its markup is not renderable.collection-grid,catalog-facets.ts,FilterSidebar,product-results,collection-presentation.tsandcontent-presentation.tsare retired.Before merging
forward.field_code, articleforward.locationandforward.coordinatesALL_PRODUCTStemplate anall-productssection (bun run seed:weaverse --applyseeds it)/collections/allae20d9d,a2af484) do not build on their ownNotes for review
AGENTS.mdnow forbids handle allowlists and presentation profile tables, states that nothing falls back to fixtures in Shopify mode, and states that a Weaverse page renders exactly as authored. Only Pilot's file organization was adopted; no Pilot source is translated. Removing static mode entirely is tracked in #81.Verification
bun install --frozen-lockfileandbun run checkpass: 380 node tests, 189 DOM tests,check:graphql, build,check:themeandcheck:routes.bun run verify:shopifypasses against the live store, including live menus, footer, collections, pages, articles and policies.bun run smoke:routespasses except/shop, which stays blank until its template is populated.bun run test:browserhas not been run on this revision.Specs:
.weaverse/specs/2026-09-21--collection-route-filters/and.weaverse/specs/2026-09-22--store-driven-catalog/.