Skip to main content
Load the composed menu for the store, channel, and fulfillment the customer is using. @fire-spark/storefront turns that tree into sections. @fire-spark/rn-ui keeps the category nav on the section that is on screen. @fire-spark/react-ui (web) stays on the offset hook. Virtualized lists are a React Native concern.

Flatten the catalog

flattenMenuCategories walks the menu depth-first. Each section’s instanceId is the path of category ids (food/mains), so the same category can appear under two parents.

Rows for a virtualized list

buildMenuRows turns those sections into rows a list can recycle. It does not render anything. The product type is whatever you store in the map.
Each section starts with a header row (sectionId is instanceId). Product rows follow, and index restarts at 0 in every section. An empty row is added only when the section has no products and no CATEGORY items, so a parent that only groups subcategories does not show an empty state. Keys stay unique when one product sits in two sections: first is true only on the first header.
spanMenuRow sets layout.span to the list’s column count when the row is not a product. Product cells keep a span of one.

Scroll-spy on a plain scroll view

Pass section ids and the scroll view ref. registerCategory records each section’s y. onScroll picks the last section whose top is at or above the offset line. selectCategory scrolls there.
Put active.registerCategory(section.instanceId) on the section’s onLayout, and active.onScroll on the scroll view.

Scroll-spy on a virtualized list

Off-screen rows are not mounted, so layout offsets are missing. Pass the index mode instead. The hook does not import a list package. Any ref with scrollToIndex fits, including FlashList, FlatList, and LegendList.
  • Spy. onScroll reads listRef.current.getFirstVisibleIndex() and maps that row to rowSectionIds. You can call it with no event. For FlatList, pass active.onViewableItemsChanged. It uses the lowest visible index.
  • Jump. selectCategory(id) calls scrollToIndex({ index: headerIndex.get(id), animated: true, viewOffset: offset }). Do not wait on the promise FlashList returns.
  • Lock. While the jump animates, scroll events do not move the highlight. The lock clears 140ms after scrolling stops, or after 1s if the list never scrolls.
  • Deep link. initialCategoryId is the active section before the first scroll. If that id leaves headerIndex, the active id falls back to the first section.
registerCategory is a no-op in this mode. Category* primitives are plain views, so they render inside a list cell with no other changes.