diff --git a/examples/react/dynamic-widgets-v2/index.html b/examples/react/dynamic-widgets-v2/index.html new file mode 100644 index 00000000000..1e8d7840357 --- /dev/null +++ b/examples/react/dynamic-widgets-v2/index.html @@ -0,0 +1,15 @@ + + + + + + DynamicWidgets v2 — 1000 Facets PoC + + +
+ + + + diff --git a/examples/react/dynamic-widgets-v2/package.json b/examples/react/dynamic-widgets-v2/package.json new file mode 100644 index 00000000000..b4be768a1bf --- /dev/null +++ b/examples/react/dynamic-widgets-v2/package.json @@ -0,0 +1,28 @@ +{ + "name": "example-react-instantsearch-dynamic-widgets-v2", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "start": "vite", + "build": "vite build" + }, + "dependencies": { + "algoliasearch": "5.1.1", + "algoliasearch-helper": "3.28.0", + "instantsearch-ui-components": "0.22.0", + "instantsearch.css": "8.12.0", + "instantsearch.js": "4.92.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-instantsearch": "7.28.0", + "react-instantsearch-core": "7.28.0" + }, + "devDependencies": { + "@types/react": "19.0.3", + "@vitejs/plugin-react": "4.2.1", + "typescript": "5.5.2", + "vite": "5.0.7", + "vite-plugin-commonjs": "0.10.0" + } +} diff --git a/examples/react/dynamic-widgets-v2/seed-index.mjs b/examples/react/dynamic-widgets-v2/seed-index.mjs new file mode 100644 index 00000000000..8a0d4ea92ec --- /dev/null +++ b/examples/react/dynamic-widgets-v2/seed-index.mjs @@ -0,0 +1,528 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * Seed script for DynamicWidgets v2 PoC. + * + * Creates an Algolia index with 1000+ facet attributes, each with diverse + * values, plus hierarchical categories. Configures attributesForFaceting + * and facetOrdering so DynamicWidgets can discover them via facets: ['*']. + * + * Usage: + * ALGOLIA_APP_ID=xxx ALGOLIA_ADMIN_API_KEY=yyy node seed-index.mjs + * + * Optional env: + * ALGOLIA_INDEX_NAME (default: dynamic_facets_v2_poc) + * NUM_ATTRIBUTES (default: 1050) + * NUM_RECORDS (default: 5000) + */ + +import { algoliasearch } from 'algoliasearch'; + +const APP_ID = process.env.ALGOLIA_APP_ID; +const ADMIN_KEY = process.env.ALGOLIA_ADMIN_API_KEY; +const INDEX_NAME = process.env.ALGOLIA_INDEX_NAME || 'dynamic_facets_v2_poc'; +const NUM_ATTRIBUTES = parseInt(process.env.NUM_ATTRIBUTES || '1050', 10); +const NUM_RECORDS = parseInt(process.env.NUM_RECORDS || '5000', 10); + +if (!APP_ID || !ADMIN_KEY) { + throw new Error( + 'Missing ALGOLIA_APP_ID or ALGOLIA_ADMIN_API_KEY environment variables.' + ); +} + +const client = algoliasearch(APP_ID, ADMIN_KEY); + +// --------------------------------------------------------------------------- +// Value generators — produce realistic-looking facet values +// --------------------------------------------------------------------------- + +const COLORS = [ + 'Red', + 'Blue', + 'Green', + 'Yellow', + 'Black', + 'White', + 'Orange', + 'Purple', + 'Pink', + 'Brown', + 'Gray', + 'Navy', + 'Teal', + 'Coral', + 'Ivory', + 'Maroon', + 'Olive', + 'Cyan', + 'Magenta', + 'Turquoise', + 'Salmon', + 'Lavender', + 'Beige', + 'Indigo', + 'Mint', + 'Peach', + 'Charcoal', + 'Gold', + 'Silver', + 'Bronze', +]; + +const MATERIALS = [ + 'Cotton', + 'Polyester', + 'Leather', + 'Silk', + 'Wool', + 'Linen', + 'Denim', + 'Nylon', + 'Rayon', + 'Velvet', + 'Satin', + 'Cashmere', + 'Suede', + 'Canvas', + 'Bamboo', + 'Hemp', + 'Fleece', + 'Gore-Tex', + 'Kevlar', + 'Lycra', +]; + +const BRANDS = [ + 'Acme', + 'Globex', + 'Initech', + 'Umbrella', + 'Stark', + 'Wayne', + 'Aperture', + 'Cyberdyne', + 'Wonka', + 'Hooli', + 'Pied Piper', + 'Dunder Mifflin', + 'Weyland', + 'Oscorp', + 'LexCorp', + 'Tyrell', + 'Soylent', + 'Massive Dynamic', + 'Gekko', + 'Prestige Worldwide', + 'Vandelay', + 'Bluth', + 'Sterling Cooper', + 'Wernham Hogg', + 'Dharma', + 'Oceanic', + 'Hanso', + 'Veridian', + 'Vought', + 'GeneriCo', +]; + +const SIZES = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']; + +const ADJECTIVES = [ + 'Premium', + 'Classic', + 'Modern', + 'Vintage', + 'Eco', + 'Ultra', + 'Pro', + 'Deluxe', + 'Essential', + 'Original', + 'Advanced', + 'Basic', + 'Elite', + 'Supreme', + 'Natural', + 'Organic', + 'Artisan', + 'Handcrafted', + 'Limited', + 'Custom', +]; + +const NOUNS = [ + 'Widget', + 'Gadget', + 'Device', + 'Tool', + 'Component', + 'Module', + 'Unit', + 'Element', + 'Part', + 'Item', + 'Product', + 'System', + 'Kit', + 'Set', + 'Pack', + 'Bundle', + 'Collection', + 'Series', + 'Edition', + 'Model', +]; + +const CATEGORIES_L1 = [ + 'Electronics', + 'Clothing', + 'Home & Garden', + 'Sports', + 'Books', + 'Toys', + 'Food & Drink', + 'Health', + 'Automotive', + 'Office', +]; + +const CATEGORIES_L2 = { + Electronics: ['Phones', 'Laptops', 'Audio', 'Cameras', 'Accessories'], + Clothing: ['Shirts', 'Pants', 'Shoes', 'Jackets', 'Accessories'], + 'Home & Garden': ['Furniture', 'Kitchen', 'Lighting', 'Decor', 'Tools'], + Sports: ['Running', 'Cycling', 'Swimming', 'Gym', 'Outdoor'], + Books: ['Fiction', 'Non-fiction', 'Science', 'History', 'Comics'], + Toys: ['Board Games', 'Action Figures', 'Puzzles', 'Dolls', 'Building'], + 'Food & Drink': ['Snacks', 'Beverages', 'Organic', 'Gourmet', 'Supplements'], + Health: ['Fitness', 'Wellness', 'Skincare', 'Vitamins', 'First Aid'], + Automotive: ['Parts', 'Accessories', 'Tools', 'Cleaning', 'Electronics'], + Office: ['Stationery', 'Furniture', 'Electronics', 'Supplies', 'Storage'], +}; + +const CATEGORIES_L3 = {}; +for (const [l1, l2s] of Object.entries(CATEGORIES_L2)) { + for (const l2 of l2s) { + CATEGORIES_L3[`${l1} > ${l2}`] = [ + `${l2} Type A`, + `${l2} Type B`, + `${l2} Type C`, + `${l2} Premium`, + `${l2} Budget`, + ]; + } +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function pickN(arr, min, max) { + const n = min + Math.floor(Math.random() * (max - min + 1)); + const shuffled = [...arr].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.min(n, arr.length)); +} + +function randomInt(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +// --------------------------------------------------------------------------- +// Generate attribute names — 1050 unique facet attributes +// --------------------------------------------------------------------------- + +// First ~20 are "realistic" named attributes +const NAMED_ATTRIBUTES = [ + 'color', + 'material', + 'brand', + 'size', + 'price_range', + 'rating', + 'in_stock', + 'free_shipping', + 'condition', + 'warranty', + 'weight_class', + 'origin_country', + 'season', + 'style', + 'pattern', + 'fit', + 'gender', + 'age_group', + 'eco_certified', + 'bestseller', +]; + +// Attribute value pools for the named attributes +const NAMED_VALUES = { + color: COLORS, + material: MATERIALS, + brand: BRANDS, + size: SIZES, + price_range: [ + 'Under $10', + '$10-$25', + '$25-$50', + '$50-$100', + '$100-$250', + '$250-$500', + '$500+', + ], + rating: ['1 star', '2 stars', '3 stars', '4 stars', '5 stars'], + in_stock: ['Yes', 'No'], + free_shipping: ['Yes', 'No'], + condition: [ + 'New', + 'Refurbished', + 'Used - Like New', + 'Used - Good', + 'Used - Fair', + ], + warranty: ['No Warranty', '1 Year', '2 Years', '3 Years', 'Lifetime'], + weight_class: ['Ultralight', 'Light', 'Medium', 'Heavy', 'Extra Heavy'], + origin_country: [ + 'USA', + 'China', + 'Germany', + 'Japan', + 'UK', + 'France', + 'Italy', + 'Canada', + 'South Korea', + 'India', + 'Brazil', + 'Australia', + 'Mexico', + 'Sweden', + 'Switzerland', + ], + season: ['Spring', 'Summer', 'Fall', 'Winter', 'All Season'], + style: [ + 'Casual', + 'Formal', + 'Sporty', + 'Outdoor', + 'Bohemian', + 'Minimalist', + 'Industrial', + ], + pattern: [ + 'Solid', + 'Striped', + 'Plaid', + 'Floral', + 'Geometric', + 'Abstract', + 'Camo', + 'Polka Dot', + ], + fit: ['Slim', 'Regular', 'Relaxed', 'Oversized', 'Tailored'], + gender: ['Men', 'Women', 'Unisex', 'Kids'], + age_group: ['Infant', 'Toddler', 'Child', 'Teen', 'Adult', 'Senior'], + eco_certified: ['Yes', 'No'], + bestseller: ['Yes', 'No'], +}; + +// Generate remaining attributes: "attr_0001" through "attr_XXXX" +const generatedAttributes = []; +for (let i = 0; i < NUM_ATTRIBUTES - NAMED_ATTRIBUTES.length; i++) { + generatedAttributes.push(`attr_${String(i + 1).padStart(4, '0')}`); +} + +const ALL_ATTRIBUTES = [...NAMED_ATTRIBUTES, ...generatedAttributes]; + +// Pre-generate value pools for generated attributes (5-30 values each) +const GENERATED_VALUES = {}; +for (const attr of generatedAttributes) { + const numValues = randomInt(5, 30); + const values = []; + for (let i = 0; i < numValues; i++) { + values.push(`${pick(ADJECTIVES)} ${pick(NOUNS)} ${i + 1}`); + } + GENERATED_VALUES[attr] = values; +} + +// --------------------------------------------------------------------------- +// Generate records +// --------------------------------------------------------------------------- + +console.log( + `Generating ${NUM_RECORDS} records with ${ALL_ATTRIBUTES.length} facet attributes...` +); + +const records = []; + +for (let i = 0; i < NUM_RECORDS; i++) { + const record = { + objectID: `record_${String(i + 1).padStart(6, '0')}`, + name: `${pick(ADJECTIVES)} ${pick(NOUNS)} #${i + 1}`, + description: `A ${pick(ADJECTIVES).toLowerCase()} ${pick( + NOUNS + ).toLowerCase()} made of ${pick(MATERIALS).toLowerCase()} by ${pick( + BRANDS + )}.`, + price: parseFloat((Math.random() * 500 + 1).toFixed(2)), + }; + + // Hierarchical categories + const l1 = pick(CATEGORIES_L1); + const l2 = pick(CATEGORIES_L2[l1]); + const l3Candidates = CATEGORIES_L3[`${l1} > ${l2}`] || []; + const l3 = l3Candidates.length > 0 ? pick(l3Candidates) : null; + + record['hierarchicalCategories.lvl0'] = l1; + record['hierarchicalCategories.lvl1'] = `${l1} > ${l2}`; + if (l3) { + record['hierarchicalCategories.lvl2'] = `${l1} > ${l2} > ${l3}`; + } + + // Named attributes — each record gets a random subset (~60-80%) + for (const attr of NAMED_ATTRIBUTES) { + if (Math.random() < 0.7) { + const values = NAMED_VALUES[attr]; + if (values.length <= 2) { + record[attr] = pick(values); + } else { + // Sometimes multi-value (for refinementList testing) + record[attr] = Math.random() < 0.8 ? pick(values) : pickN(values, 1, 3); + } + } + } + + // Generated attributes — each record gets ~15-25% of them (sparse) + // This creates a realistic scenario where not every record has every attribute + for (const attr of generatedAttributes) { + if (Math.random() < 0.2) { + record[attr] = pick(GENERATED_VALUES[attr]); + } + } + + records.push(record); +} + +// --------------------------------------------------------------------------- +// Push to Algolia +// --------------------------------------------------------------------------- + +console.log(`Pushing ${records.length} records to ${INDEX_NAME}...`); + +// Save records in batches of 1000 +const BATCH_SIZE = 1000; +for (let i = 0; i < records.length; i += BATCH_SIZE) { + const batch = records.slice(i, i + BATCH_SIZE); + await client.saveObjects({ + indexName: INDEX_NAME, + objects: batch, + }); + console.log( + ` Pushed records ${i + 1}–${Math.min(i + BATCH_SIZE, records.length)}` + ); +} + +// --------------------------------------------------------------------------- +// Configure index settings +// --------------------------------------------------------------------------- + +console.log('Configuring index settings...'); + +// All attributes should be facetable +const attributesForFaceting = [ + 'searchable(color)', + 'searchable(material)', + 'searchable(brand)', + 'size', + 'price_range', + 'rating', + 'in_stock', + 'free_shipping', + 'condition', + 'warranty', + 'weight_class', + 'origin_country', + 'season', + 'style', + 'pattern', + 'fit', + 'gender', + 'age_group', + 'eco_certified', + 'bestseller', + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + ...generatedAttributes, +]; + +// facetOrdering: show named attributes first, then generated ones +const facetOrder = [ + 'hierarchicalCategories.lvl0', + 'brand', + 'color', + 'material', + 'size', + 'price_range', + 'rating', + 'condition', + 'origin_country', + 'season', + 'style', + 'pattern', + 'fit', + 'gender', + 'age_group', + 'weight_class', + 'warranty', + 'in_stock', + 'free_shipping', + 'eco_certified', + 'bestseller', + ...generatedAttributes, +]; + +await client.setSettings({ + indexName: INDEX_NAME, + indexSettings: { + searchableAttributes: ['name', 'description', 'brand', 'color', 'material'], + attributesForFaceting, + renderingContent: { + facetOrdering: { + facets: { + order: facetOrder, + }, + values: { + brand: { sortRemainingBy: 'count' }, + color: { sortRemainingBy: 'count' }, + material: { sortRemainingBy: 'count' }, + size: { sortRemainingBy: 'count' }, + price_range: { sortRemainingBy: 'count' }, + rating: { sortRemainingBy: 'count' }, + }, + }, + }, + }, +}); + +// Retrieve the search-only API key +const apiKeys = await client.listApiKeys(); +const searchKey = apiKeys.keys.find( + (k) => k.acl.includes('search') && !k.acl.includes('addObject') +); + +console.log('\n✅ Done!\n'); +console.log(`Index: ${INDEX_NAME}`); +console.log(`App ID: ${APP_ID}`); +console.log(`Records: ${NUM_RECORDS}`); +console.log(`Facet attributes: ${ALL_ATTRIBUTES.length}`); +console.log(` Named: ${NAMED_ATTRIBUTES.length}`); +console.log(` Generated: ${generatedAttributes.length}`); +console.log(` Hierarchical: 3 levels (hierarchicalCategories.lvl0/1/2)`); +if (searchKey) { + console.log(`\nSearch-only API key: ${searchKey.value}`); +} +console.log(`\nUpdate your App.tsx with:`); +console.log( + ` const searchClient = algoliasearch('${APP_ID}', '');` +); +console.log(` const INDEX_NAME = '${INDEX_NAME}';`); diff --git a/examples/react/dynamic-widgets-v2/src/App.css b/examples/react/dynamic-widgets-v2/src/App.css new file mode 100644 index 00000000000..a72efcaeb39 --- /dev/null +++ b/examples/react/dynamic-widgets-v2/src/App.css @@ -0,0 +1,190 @@ +:root { + --sidebar-width: 320px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; +} + +.app { + min-height: 100vh; +} + +.header { + background: #fff; + padding: 16px 24px; + border-bottom: 1px solid #ddd; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + margin: 0 0 4px; + font-size: 20px; +} + +.header p { + margin: 0 0 12px; + color: #666; + font-size: 13px; +} + +.controls { + display: flex; + align-items: center; + gap: 8px; +} + +.controls button { + padding: 8px 16px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: all 0.15s; +} + +.controls button:hover { + background: #f0f0f0; +} + +.controls button.active { + background: #003dff; + color: #fff; + border-color: #003dff; +} + +.controls select { + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +.main { + padding: 16px 24px; +} + +.placeholder { + background: #fff; + border: 2px dashed #ddd; + border-radius: 8px; + padding: 40px; + text-align: center; + color: #666; +} + +.search-layout { + display: flex; + gap: 24px; +} + +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; +} + +.results { + flex: 1; + min-width: 0; +} + +.perf-meter { + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 4px; + padding: 8px 12px; + margin-bottom: 12px; + font-size: 13px; +} + +.facets-container { + max-height: calc(100vh - 240px); + overflow-y: auto; + padding-right: 4px; +} + +.facet-panel { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; +} + +.facet-title { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.facet-list { + list-style: none; + margin: 0; + padding: 0; +} + +.facet-item { + padding: 2px 0; +} + +.facet-item label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; +} + +.facet-item input[type='checkbox'] { + margin: 0; +} + +.facet-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.facet-count { + color: #999; + font-size: 12px; + flex-shrink: 0; +} + +.facet-show-more { + margin-top: 4px; + padding: 2px 0; + border: none; + background: none; + color: #003dff; + cursor: pointer; + font-size: 12px; +} + +.facet-show-more:hover { + text-decoration: underline; +} + +/* Satellite theme overrides for v1 */ +.sidebar .ais-SearchBox { + margin-bottom: 12px; +} + +.sidebar .ais-RefinementList-list { + margin: 0; + padding: 0; +} diff --git a/examples/react/dynamic-widgets-v2/src/App.tsx b/examples/react/dynamic-widgets-v2/src/App.tsx new file mode 100644 index 00000000000..fae99dd35f6 --- /dev/null +++ b/examples/react/dynamic-widgets-v2/src/App.tsx @@ -0,0 +1,307 @@ +import { liteClient as algoliasearch } from 'algoliasearch/lite'; +import { history } from 'instantsearch.js/es/lib/routers'; +import React, { useState } from 'react'; +import { + InstantSearch, + SearchBox, + Hits, + RefinementList, + DynamicWidgets, + Configure, + DynamicWidgetsV2, + DynamicWidgetsV2Composed, + HierarchicalMenu, + Menu, + ClearRefinements, +} from 'react-instantsearch'; + +import type { WidgetDescriptor } from 'instantsearch.js/es/connectors/dynamic-facets/connectDynamicFacets'; + +import 'instantsearch.css/themes/satellite.css'; +import './App.css'; + +// --------------------------------------------------------------------------- +// Configuration — real Algolia index +// --------------------------------------------------------------------------- + +// After running `node seed-index.mjs`, replace these with your own credentials. +const searchClient = algoliasearch( + 'F4T6CUV2AH', + '93aba0bf5908533b213d93b2410ded0c' +); + +const INDEX_NAME = 'dynamic_facets_v2_poc'; + +// --------------------------------------------------------------------------- +// V1 Fallback component — instantiates a full per facet +// --------------------------------------------------------------------------- + +function V1FallbackComponent({ attribute }: { attribute: string }) { + if (attribute.startsWith('hierarchicalCategories')) { + if (attribute !== 'hierarchicalCategories.lvl0') { + return null; // Only render the first level as a facet, the others are rendered as part of the hierarchical menu + } + return ( +
+

{attribute}

+ +
+ ); + } + + if (attribute === 'brand') { + return ( +
+

{attribute}

+ +
+ ); + } + + return ( +
+

{attribute}

+ +
+ ); +} + +// --------------------------------------------------------------------------- +// The widgets function (the v2 public API) +// --------------------------------------------------------------------------- + +const widgetsFn = (attribute: string): WidgetDescriptor | false => { + // Everything is a refinement list for this demo + if (attribute.startsWith('hierarchicalCategories')) { + if (attribute !== 'hierarchicalCategories.lvl0') { + return false; // Only render the first level as a facet, the others are rendered as part of the hierarchical menu + } + return { + type: 'hierarchicalMenu', + limit: 5, + showMoreLimit: 20, + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + ], + }; + } + + if (attribute === 'brand') { + return { type: 'menu', limit: 5, showMoreLimit: 20 }; + } + + return { type: 'refinementList', limit: 5, showMoreLimit: 20 }; +}; + +// Stable reference to avoid re-renders +const stableWidgetsFn = widgetsFn; + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +export function App() { + const [mode, setMode] = useState<'v2' | 'v2-composed' | 'v1' | 'none'>( + 'none' + ); + const [maxFacets, setMaxFacets] = useState(10); + + return ( +
+
+

DynamicWidgets v2 — PoC

+

+ Compare the v2 shared-store approach vs v1 per-widget approach. +
+ Using real Algolia index: {INDEX_NAME} +

+
+ + + + + +
+
+ +
+ {mode === 'none' && ( +
+

Select V1 or V2 above to render facets.

+

+ V2 uses a single connector for all facets (the + RFC approach). +
+ V1 mounts a separate{' '} + <RefinementList> widget per facet (current + behavior). +

+
+ )} + + {mode === 'v2' && ( + + +
+
+ +
+ + items.slice(0, maxFacets)} + /> + {/* Zero-config: no fallbackComponent needed! Built-in + renderers handle refinementList, menu, hierarchicalMenu + automatically based on descriptor types. */} +
+
+
+ ( +
+ {hit.name || hit.title || hit.objectID} + {hit.description &&

{hit.description}

} +
+ )} + /> +
+
+
+ )} + + {mode === 'v2-composed' && ( + + +
+
+ +
+ + items.slice(0, maxFacets)} + /> +
+
+
+ ( +
+ {hit.name || hit.title || hit.objectID} + {hit.description &&

{hit.description}

} +
+ )} + /> +
+
+
+ )} + + {mode === 'v1' && ( + + +
+
+ +
+ + items.slice(0, maxFacets)} + /> +
+
+
+ ( +
+ {hit.name || hit.title || hit.objectID} + {hit.description &&

{hit.description}

} +
+ )} + /> +
+
+
+ )} +
+
+ ); +} diff --git a/examples/react/dynamic-widgets-v2/src/index.tsx b/examples/react/dynamic-widgets-v2/src/index.tsx new file mode 100644 index 00000000000..f16daaab174 --- /dev/null +++ b/examples/react/dynamic-widgets-v2/src/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +const root = createRoot(document.getElementById('root')!); +root.render( + + + +); diff --git a/examples/react/dynamic-widgets-v2/vite.config.mjs b/examples/react/dynamic-widgets-v2/vite.config.mjs new file mode 100644 index 00000000000..05c30b8613c --- /dev/null +++ b/examples/react/dynamic-widgets-v2/vite.config.mjs @@ -0,0 +1,33 @@ +import { resolve } from 'path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import commonjs from 'vite-plugin-commonjs'; + +// Resolve to source files so we pick up the new v2 PoC files +// without needing to rebuild the packages. +const packages = resolve(__dirname, '..', '..', '..', 'packages'); + +export default defineConfig({ + plugins: [commonjs(), react()], + resolve: { + alias: { + 'react-instantsearch-core': resolve( + packages, + 'react-instantsearch-core/src' + ), + 'react-instantsearch': resolve(packages, 'react-instantsearch/src'), + 'instantsearch-ui-components': resolve( + packages, + 'instantsearch-ui-components/src' + ), + 'instantsearch.js/es': resolve(packages, 'instantsearch.js/src'), + 'instantsearch.js': resolve(packages, 'instantsearch.js/src'), + }, + }, + build: { + commonjsOptions: { + requireReturnsDefault: 'preferred', + }, + }, +}); diff --git a/packages/instantsearch.js/src/connectors/dynamic-facets-composed/connectDynamicFacetsComposed.ts b/packages/instantsearch.js/src/connectors/dynamic-facets-composed/connectDynamicFacetsComposed.ts new file mode 100644 index 00000000000..53150d94a39 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/dynamic-facets-composed/connectDynamicFacetsComposed.ts @@ -0,0 +1,596 @@ +/** + * connectDynamicFacetsComposed — PoC connector for DynamicWidgets v2 + * using virtual (never-mounted) widget instances from the standalone + * connectors instead of reimplementing their logic. + * + * One widget manages ALL dynamic facets by: + * 1. Delegating getWidgetSearchParameters to virtual widgets + * 2. Delegating getWidgetRenderState to virtual widgets + * 3. Delegating getWidgetUiState to virtual widgets + * 4. Normalizing the heterogeneous render states into FacetSlice + */ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, + warning, +} from '../../lib/utils'; + +import connectHierarchicalMenu from '../hierarchical-menu/connectHierarchicalMenu'; +import connectMenu from '../menu/connectMenu'; +import connectRefinementList from '../refinement-list/connectRefinementList'; +import connectToggleRefinement from '../toggle-refinement/connectToggleRefinement'; + +import type { + Connector, + TransformItems, + TransformItemsMetadata, + Widget, +} from '../../types'; +import type { HierarchicalMenuItem } from '../hierarchical-menu/connectHierarchicalMenu'; + +// Re-export descriptor types from the monolithic connector for compatibility +export type { + WidgetDescriptor, + RefinementListDescriptor, + MenuDescriptor, + HierarchicalMenuDescriptor, + ToggleRefinementDescriptor, + NumericMenuDescriptor, + RangeDescriptor, + RatingMenuDescriptor, +} from '../dynamic-facets/connectDynamicFacets'; + +import type { WidgetDescriptor } from '../dynamic-facets/connectDynamicFacets'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'dynamic-facets-composed', + connector: true, +}); + +// --------------------------------------------------------------------------- +// Facet slice types — identical to the monolithic connector +// --------------------------------------------------------------------------- + +export type RefinementListItem = { + value: string; + label: string; + count: number; + isRefined: boolean; + highlighted?: string; +}; + +export type FacetSlice = { + type: WidgetDescriptor['type']; + attribute: string; + items: RefinementListItem[]; + /** For hierarchical menu slices, this contains the nested tree */ + hierarchicalItems?: HierarchicalMenuItem[]; + canRefine: boolean; + isShowingMore: boolean; + canToggleShowMore: boolean; + hasExhaustiveItems: boolean; + /** Per-attribute component from the descriptor (opaque at connector level, used by framework adapters) */ + descriptorComponent?: any; +}; + +// --------------------------------------------------------------------------- +// Connector params & render state +// --------------------------------------------------------------------------- + +export type DynamicFacetsComposedConnectorParams = { + widgets: (attribute: string) => WidgetDescriptor | false; + facets?: ['*'] | string[]; + maxValuesPerFacet?: number; + transformItems?: TransformItems< + string, + Omit & { + results: NonNullable; + } + >; +}; + +export type DynamicFacetsComposedRenderState = { + attributesToRender: string[]; + facets: Record; + refine: (attribute: string, value: string) => void; + toggleShowMore: (attribute: string) => void; + createURL: (attribute: string, value: string) => string; +}; + +export type DynamicFacetsComposedWidgetDescription = { + $$type: 'ais.dynamicFacetsComposed'; + renderState: DynamicFacetsComposedRenderState; + indexRenderState: { + dynamicFacets: DynamicFacetsComposedRenderState; + }; + indexUiState: { + refinementList?: Record; + menu?: Record; + hierarchicalMenu?: Record; + toggle?: Record; + }; +}; + +export type DynamicFacetsComposedConnector = Connector< + DynamicFacetsComposedWidgetDescription, + DynamicFacetsComposedConnectorParams +>; + +// --------------------------------------------------------------------------- +// Helper: resolve widget descriptor +// --------------------------------------------------------------------------- + +function resolveDescriptor( + widgetsFn: DynamicFacetsComposedConnectorParams['widgets'], + attribute: string +): WidgetDescriptor | null { + const desc = widgetsFn(attribute); + if (desc === false) return null; + return desc; +} + +// --------------------------------------------------------------------------- +// The connector +// --------------------------------------------------------------------------- + +const connectDynamicFacetsComposed: DynamicFacetsComposedConnector = + function connectDynamicFacetsComposed(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + widgets: widgetsFn, + maxValuesPerFacet = 20, + facets = ['*'], + transformItems = ((items: string[]) => items) as NonNullable< + DynamicFacetsComposedConnectorParams['transformItems'] + >, + } = widgetParams; + + if (typeof widgetsFn !== 'function') { + throw new Error(withUsage('The `widgets` option expects a function.')); + } + + // --------------------------------------------------------------- + // Virtual widget instances — keyed by attribute, created on demand + // These are NEVER mounted in the Index widget tree; we only call + // their getWidgetSearchParameters, getWidgetUiState, and + // getWidgetRenderState methods directly. + // --------------------------------------------------------------- + const virtualWidgets = new Map(); + + // Cached render state per virtual widget (from last getWidgetRenderState) + const cachedVirtualRenderStates = new Map(); + + // Attributes discovered from facetOrdering + let knownAttributes: string[] = []; + + // Saved from last render() call for toggleShowMore re-render + let lastRenderOptions: any = null; + + function getOrCreateVirtual( + attribute: string, + desc: WidgetDescriptor + ): Widget { + const existing = virtualWidgets.get(attribute); + if (existing) return existing; + + let widget: Widget; + switch (desc.type) { + case 'refinementList': { + const limit = desc.limit ?? 10; + const showMoreLimit = desc.showMoreLimit ?? 20; + widget = connectRefinementList(() => {})({ + attribute, + operator: desc.operator ?? 'or', + limit, + showMore: showMoreLimit > limit, + showMoreLimit, + }); + break; + } + case 'menu': { + const limit = desc.limit ?? 10; + const showMoreLimit = desc.showMoreLimit ?? 20; + widget = connectMenu(() => {})({ + attribute, + limit, + showMore: showMoreLimit > limit, + showMoreLimit, + }); + break; + } + case 'hierarchicalMenu': { + const limit = desc.limit ?? 10; + const showMoreLimit = desc.showMoreLimit ?? 20; + widget = connectHierarchicalMenu(() => {})({ + attributes: desc.attributes, + separator: desc.separator ?? ' > ', + rootPath: desc.rootPath ?? null, + showParentLevel: desc.showParentLevel ?? true, + limit, + showMore: showMoreLimit > limit, + showMoreLimit, + }); + break; + } + case 'toggleRefinement': { + widget = connectToggleRefinement(() => {})({ + attribute, + on: desc.on as any, + off: desc.off as any, + }); + break; + } + default: + // For types not yet supported (numericMenu, range, ratingMenu), + // fall back to refinementList + widget = connectRefinementList(() => {})({ + attribute, + limit: 10, + }); + break; + } + + virtualWidgets.set(attribute, widget); + return widget; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const widgetObj = { + $$type: 'ais.dynamicFacetsComposed' as const, + + init(initOptions: any) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions: any) { + lastRenderOptions = renderOptions; + const renderState = this.getWidgetRenderState(renderOptions); + + // ------------------------------------------------------- + // Two-search bootstrap: register facets discovered from + // facetOrdering that weren't registered during init. + // We directly modify helper.state (same as monolithic PoC) + // because getWidgetSearchParameters is not re-invoked. + // ------------------------------------------------------- + const { helper } = renderOptions; + if (helper && knownAttributes.length > 0) { + let needsReSearch = false; + let params = helper.state; + + for (const attr of knownAttributes) { + const desc = resolveDescriptor(widgetsFn, attr); + if (!desc) continue; + + switch (desc.type) { + case 'menu': { + if (!params.isHierarchicalFacet(attr)) { + params = params.addHierarchicalFacet({ + name: attr, + attributes: [attr], + }); + needsReSearch = true; + } + break; + } + case 'hierarchicalMenu': { + const hierName = desc.attributes[0]; + if (!params.isHierarchicalFacet(hierName)) { + params = params.addHierarchicalFacet({ + name: hierName, + attributes: desc.attributes, + separator: desc.separator ?? ' > ', + rootPath: desc.rootPath ?? null, + showParentLevel: desc.showParentLevel ?? true, + }); + needsReSearch = true; + } + break; + } + case 'refinementList': { + const isDisjunctive = (desc.operator ?? 'or') === 'or'; + if (isDisjunctive && !params.isDisjunctiveFacet(attr)) { + params = params.addDisjunctiveFacet(attr); + needsReSearch = true; + } else if ( + !isDisjunctive && + !params.isConjunctiveFacet(attr) + ) { + params = params.addFacet(attr); + needsReSearch = true; + } + break; + } + case 'toggleRefinement': { + if (!params.isDisjunctiveFacet(attr)) { + params = params.addDisjunctiveFacet(attr); + needsReSearch = true; + } + break; + } + default: + break; + } + } + + if (needsReSearch) { + helper.setState(params); + renderOptions.instantSearchInstance.scheduleSearch(); + } + } + + renderFn( + { + ...renderState, + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + // --------------------------------------------------------------- + // Search parameters: delegate to virtual widgets + // This is where the composition shines — each virtual widget + // handles its own facet registration, maxValuesPerFacet, and + // uiState→refinement mapping. + // --------------------------------------------------------------- + getWidgetSearchParameters(searchParameters: any, { uiState }: any) { + let params = searchParameters; + + // Global facet request for discovery + for (const facet of facets) { + if (!params.isConjunctiveFacet(facet)) { + params = params.addFacet(facet); + } + } + + params = params.setQueryParameter( + 'maxValuesPerFacet', + Math.max(maxValuesPerFacet || 0, params.maxValuesPerFacet || 0) + ); + + // Merge known + refined attributes from uiState + const attributesToRegister = new Set(knownAttributes); + + const rl = uiState.refinementList || {}; + for (const attr of Object.keys(rl)) { + if (rl[attr]?.length) attributesToRegister.add(attr); + } + const menu = uiState.menu || {}; + for (const attr of Object.keys(menu)) { + if (menu[attr]) attributesToRegister.add(attr); + } + const toggle = uiState.toggle || {}; + for (const attr of Object.keys(toggle)) { + if (toggle[attr]) attributesToRegister.add(attr); + } + const hierMenu = uiState.hierarchicalMenu || {}; + for (const attr of Object.keys(hierMenu)) { + if (hierMenu[attr]?.length) attributesToRegister.add(attr); + } + + // Delegate to each virtual widget's getWidgetSearchParameters + for (const attribute of attributesToRegister) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + const vw = getOrCreateVirtual(attribute, desc); + if (vw.getWidgetSearchParameters) { + params = vw.getWidgetSearchParameters(params, { uiState }); + } + } + + return params; + }, + + // --------------------------------------------------------------- + // UI state: delegate to virtual widgets + // --------------------------------------------------------------- + getWidgetUiState(uiState: any, options: any) { + let result = { ...uiState }; + + for (const [, vw] of virtualWidgets) { + if (vw.getWidgetUiState) { + result = vw.getWidgetUiState(result, options); + } + } + + return result; + }, + + // --------------------------------------------------------------- + // Render state: call each virtual widget, normalize to FacetSlice + // --------------------------------------------------------------- + getRenderState(renderState: any, renderOptions: any) { + return { + ...renderState, + dynamicFacets: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions: any) { + const { results, state } = renderOptions; + + if (!results) { + return { + attributesToRender: [], + facets: {}, + refine: () => {}, + toggleShowMore: () => {}, + createURL: () => '', + widgetParams, + }; + } + + // Determine attributes from facetOrdering + const attributesToRender = transformItems( + results.renderingContent?.facetOrdering?.facets?.order ?? [], + { results } + ); + + knownAttributes = attributesToRender; + + warning( + maxValuesPerFacet >= (state.maxValuesPerFacet || 0), + `The maxValuesPerFacet set by dynamic facets (${maxValuesPerFacet}) is smaller than one of the limits set by a widget (${state.maxValuesPerFacet}).` + ); + + // Build FacetSlice for each attribute by delegating to virtual widgets + const facetSlices: Record = {}; + + for (const attribute of attributesToRender) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + const vw = getOrCreateVirtual(attribute, desc); + + // Call the virtual widget's getWidgetRenderState with our + // real renderOptions — this lazily initializes refine, sendEvent, + // createURL inside the virtual widget. + const rs: any = vw.getWidgetRenderState!(renderOptions); + + // Cache for refine/toggleShowMore/createURL dispatching + cachedVirtualRenderStates.set(attribute, rs); + + // Normalize per type into FacetSlice + switch (desc.type) { + case 'refinementList': + facetSlices[attribute] = { + type: 'refinementList', + attribute, + items: rs.items ?? [], + canRefine: rs.canRefine ?? false, + isShowingMore: rs.isShowingMore ?? false, + canToggleShowMore: rs.canToggleShowMore ?? false, + hasExhaustiveItems: rs.hasExhaustiveItems ?? true, + descriptorComponent: desc.component, + }; + break; + case 'menu': + facetSlices[attribute] = { + type: 'menu', + attribute, + items: rs.items ?? [], + canRefine: rs.canRefine ?? false, + isShowingMore: rs.isShowingMore ?? false, + canToggleShowMore: rs.canToggleShowMore ?? false, + hasExhaustiveItems: true, + descriptorComponent: desc.component, + }; + break; + case 'hierarchicalMenu': + facetSlices[attribute] = { + type: 'hierarchicalMenu', + attribute, + items: [], + hierarchicalItems: rs.items ?? [], + canRefine: rs.canRefine ?? false, + isShowingMore: rs.isShowingMore ?? false, + canToggleShowMore: rs.canToggleShowMore ?? false, + hasExhaustiveItems: true, + descriptorComponent: desc.component, + }; + break; + case 'toggleRefinement': + facetSlices[attribute] = { + type: 'toggleRefinement', + attribute, + items: [], + canRefine: rs.canRefine ?? false, + isShowingMore: false, + canToggleShowMore: false, + hasExhaustiveItems: true, + descriptorComponent: desc.component, + }; + break; + default: + facetSlices[attribute] = { + type: desc.type, + attribute, + items: rs.items ?? [], + canRefine: rs.canRefine ?? false, + isShowingMore: rs.isShowingMore ?? false, + canToggleShowMore: rs.canToggleShowMore ?? false, + hasExhaustiveItems: rs.hasExhaustiveItems ?? true, + descriptorComponent: desc.component, + }; + break; + } + } + + // Centralized refine — dispatch to virtual widget's own refine + const refine = (attribute: string, value: string) => { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) return; + + const rs = cachedVirtualRenderStates.get(attribute); + if (!rs?.refine) return; + + if (desc.type === 'toggleRefinement') { + // Toggle's refine takes { isRefined: boolean } + rs.refine(rs.value); + } else { + // refinementList, menu, hierarchicalMenu all take (value: string) + rs.refine(value); + } + }; + + // Centralized toggleShowMore — call virtual widget's toggleShowMore, + // then re-render the composed connector with updated state + const toggleShowMore = (attribute: string) => { + const rs = cachedVirtualRenderStates.get(attribute); + if (!rs?.toggleShowMore) return; + + // This flips the virtual widget's internal isShowingMore + // and calls widget.render!(renderOptions) → no-op renderFn + rs.toggleShowMore(); + + // Re-render the composed connector to pick up the change + if (lastRenderOptions) { + const newState = + widgetObj.getWidgetRenderState(lastRenderOptions); + renderFn( + { + ...newState, + instantSearchInstance: + lastRenderOptions.instantSearchInstance, + }, + false + ); + } + }; + + // Centralized createURL — delegate to virtual widget + const createURLFn = (attribute: string, value: string) => { + const rs = cachedVirtualRenderStates.get(attribute); + if (!rs?.createURL) return '#'; + return rs.createURL(value); + }; + + return { + attributesToRender, + facets: facetSlices, + refine, + toggleShowMore, + createURL: createURLFn, + widgetParams, + }; + }, + }; + + return widgetObj as any; + }; + }; + +export default connectDynamicFacetsComposed; diff --git a/packages/instantsearch.js/src/connectors/dynamic-facets/connectDynamicFacets.ts b/packages/instantsearch.js/src/connectors/dynamic-facets/connectDynamicFacets.ts new file mode 100644 index 00000000000..08df6abfc0d --- /dev/null +++ b/packages/instantsearch.js/src/connectors/dynamic-facets/connectDynamicFacets.ts @@ -0,0 +1,1009 @@ +/** + * connectDynamicFacets — PoC connector for DynamicWidgets v2. + * + * One widget manages ALL dynamic facets: registers search parameters, + * computes render state, and dispatches refinement actions for every + * attribute returned by facetOrdering. + */ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, + warning, +} from '../../lib/utils'; + +import type { + Connector, + TransformItems, + TransformItemsMetadata, +} from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'dynamic-facets', + connector: true, +}); + +// --------------------------------------------------------------------------- +// Widget descriptor types (the public API for choosing facet behaviour) +// --------------------------------------------------------------------------- + +export type RefinementListDescriptor = { + type: 'refinementList'; + operator?: 'and' | 'or'; + limit?: number; + showMoreLimit?: number; + searchable?: boolean; + /** Per-attribute component override (React-level, opaque at connector level) */ + component?: any; +}; + +export type MenuDescriptor = { + type: 'menu'; + limit?: number; + showMoreLimit?: number; + component?: any; +}; + +export type HierarchicalMenuDescriptor = { + type: 'hierarchicalMenu'; + attributes: string[]; + separator?: string; + rootPath?: string | null; + showParentLevel?: boolean; + limit?: number; + showMoreLimit?: number; + component?: any; +}; + +export type ToggleRefinementDescriptor = { + type: 'toggleRefinement'; + on?: string | string[] | boolean; + off?: string | string[] | boolean; + component?: any; +}; + +export type NumericMenuDescriptor = { + type: 'numericMenu'; + items: Array<{ label: string; start?: number; end?: number }>; + component?: any; +}; + +export type RangeDescriptor = { + type: 'range'; + min?: number; + max?: number; + precision?: number; + component?: any; +}; + +export type RatingMenuDescriptor = { + type: 'ratingMenu'; + max?: number; + component?: any; +}; + +export type WidgetDescriptor = + | RefinementListDescriptor + | MenuDescriptor + | HierarchicalMenuDescriptor + | ToggleRefinementDescriptor + | NumericMenuDescriptor + | RangeDescriptor + | RatingMenuDescriptor; + +// --------------------------------------------------------------------------- +// Facet slice types (per-attribute render state) +// --------------------------------------------------------------------------- + +export type RefinementListItem = { + value: string; + label: string; + count: number; + isRefined: boolean; + highlighted?: string; +}; + +export type HierarchicalMenuItem = { + value: string; + label: string; + count: number; + isRefined: boolean; + data: HierarchicalMenuItem[] | null; +}; + +export type FacetSlice = { + type: WidgetDescriptor['type']; + attribute: string; + items: RefinementListItem[]; + /** For hierarchical menu slices, this contains the nested tree */ + hierarchicalItems?: HierarchicalMenuItem[]; + canRefine: boolean; + isShowingMore: boolean; + canToggleShowMore: boolean; + hasExhaustiveItems: boolean; + /** Per-attribute component from the descriptor (opaque at connector level, used by framework adapters) */ + descriptorComponent?: any; +}; + +// --------------------------------------------------------------------------- +// Connector params & render state +// --------------------------------------------------------------------------- + +export type DynamicFacetsConnectorParams = { + /** + * Maps an attribute from facetOrdering to a widget descriptor. + * Called for each attribute. Return `false` to skip. + */ + widgets: (attribute: string) => WidgetDescriptor | false; + + facets?: ['*'] | string[]; + maxValuesPerFacet?: number; + + transformItems?: TransformItems< + string, + Omit & { + results: NonNullable; + } + >; +}; + +export type DynamicFacetsRenderState = { + attributesToRender: string[]; + facets: Record; + refine: (attribute: string, value: string) => void; + toggleShowMore: (attribute: string) => void; + createURL: (attribute: string, value: string) => string; +}; + +export type DynamicFacetsWidgetDescription = { + $$type: 'ais.dynamicFacets'; + renderState: DynamicFacetsRenderState; + indexRenderState: { + dynamicFacets: DynamicFacetsRenderState; + }; + indexUiState: { + refinementList?: Record; + menu?: Record; + hierarchicalMenu?: Record; + toggle?: Record; + numericMenu?: Record; + range?: Record; + ratingMenu?: Record; + }; +}; + +export type DynamicFacetsConnector = Connector< + DynamicFacetsWidgetDescription, + DynamicFacetsConnectorParams +>; + +// --------------------------------------------------------------------------- +// Helper: resolve widget descriptor for an attribute +// --------------------------------------------------------------------------- + +function resolveDescriptor( + widgetsFn: DynamicFacetsConnectorParams['widgets'], + attribute: string +): WidgetDescriptor | null { + const desc = widgetsFn(attribute); + if (desc === false) return null; + return desc; +} + +// --------------------------------------------------------------------------- +// Helper: transform raw hierarchical facet data from the helper into +// the HierarchicalMenuItem shape the UI components expect. +// The helper returns { name, escapedValue, path, count, isRefined, data } +// but our public API uses { label, value, count, isRefined, data }. +// --------------------------------------------------------------------------- + +function prepareHierarchicalItems( + facetValues: any[], + limit?: number +): HierarchicalMenuItem[] { + const sliced = + limit !== null || limit !== undefined + ? facetValues.slice(0, limit) + : facetValues; + return sliced.map(({ name, escapedValue, data, path, ...rest }: any) => ({ + ...rest, + label: String(name), + value: String(escapedValue ?? path ?? name), + data: Array.isArray(data) ? prepareHierarchicalItems(data) : null, + })); +} + +// --------------------------------------------------------------------------- +// The connector +// --------------------------------------------------------------------------- + +const connectDynamicFacets: DynamicFacetsConnector = + function connectDynamicFacets(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + widgets: widgetsFn, + maxValuesPerFacet = 20, + facets = ['*'], + transformItems = ((items: string[]) => items) as NonNullable< + DynamicFacetsConnectorParams['transformItems'] + >, + } = widgetParams; + + if (typeof widgetsFn !== 'function') { + throw new Error(withUsage('The `widgets` option expects a function.')); + } + + // Per-attribute showMore state (client-side only) + const showMoreState = new Map(); + + // Resolved descriptors cache (cleared on each render) + let resolvedDescriptors = new Map(); + + // Attributes discovered from facetOrdering on the last render. + // Used by getWidgetSearchParameters to register them as proper + // disjunctive/conjunctive facets so that getFacetValues() works. + let knownAttributes: string[] = []; + + return { + $$type: 'ais.dynamicFacets' as const, + + init(initOptions) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + // eslint-disable-next-line complexity + render(renderOptions) { + const renderState = this.getWidgetRenderState(renderOptions); + + // After the first search, knownAttributes is populated from + // facetOrdering. Some facet types (menu, hierarchicalMenu) + // need explicit registration as hierarchical facets in the + // helper state so that getFacetValues() returns the proper + // tree data. If they aren't registered yet, update the + // helper state and schedule a fresh search. + const { helper } = renderOptions; + if (helper && knownAttributes.length > 0) { + let needsReSearch = false; + let params = helper.state; + + // eslint-disable-next-line no-restricted-syntax + for (const attr of knownAttributes) { + const desc = resolveDescriptor(widgetsFn, attr); + // eslint-disable-next-line no-continue + if (!desc) continue; + + switch (desc.type) { + case 'menu': { + if (!params.isHierarchicalFacet(attr)) { + params = params.addHierarchicalFacet({ + name: attr, + attributes: [attr], + }); + needsReSearch = true; + } + break; + } + case 'hierarchicalMenu': { + const hierDesc = desc; + const hierName = hierDesc.attributes[0]; + if (!params.isHierarchicalFacet(hierName)) { + params = params.addHierarchicalFacet({ + name: hierName, + attributes: hierDesc.attributes, + separator: hierDesc.separator ?? ' > ', + rootPath: hierDesc.rootPath ?? null, + showParentLevel: hierDesc.showParentLevel ?? true, + }); + needsReSearch = true; + } + break; + } + case 'refinementList': { + const isDisjunctive = (desc.operator ?? 'or') === 'or'; + if (isDisjunctive && !params.isDisjunctiveFacet(attr)) { + params = params.addDisjunctiveFacet(attr); + needsReSearch = true; + } else if ( + !isDisjunctive && + !params.isConjunctiveFacet(attr) + ) { + params = params.addFacet(attr); + needsReSearch = true; + } + break; + } + case 'toggleRefinement': { + if (!params.isDisjunctiveFacet(attr)) { + params = params.addDisjunctiveFacet(attr); + needsReSearch = true; + } + break; + } + default: + break; + } + } + + if (needsReSearch) { + helper.setState(params); + renderOptions.instantSearchInstance.scheduleSearch(); + } + } + + renderFn( + { + ...renderState, + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + // --------------------------------------------------------------- + // Search parameters: register facets for ALL managed attributes + // This is the key perf win — ONE call instead of N. + // --------------------------------------------------------------- + // eslint-disable-next-line complexity + getWidgetSearchParameters(searchParameters, { uiState }) { + let params = searchParameters; + + // Global facet request + // eslint-disable-next-line no-restricted-syntax + for (const facet of facets) { + params = params.addFacet(facet); + } + + params = params.setQueryParameter( + 'maxValuesPerFacet', + Math.max(maxValuesPerFacet || 0, params.maxValuesPerFacet || 0) + ); + + // Register all known attributes (discovered from the previous + // render's facetOrdering) so the helper processes them properly + // and getFacetValues() works. + // Also register any attributes with active refinements from uiState. + const attributesToRegister = new Set(knownAttributes); + + const rl = uiState.refinementList || {}; + for (const attr of Object.keys(rl)) { + if (rl[attr]?.length) attributesToRegister.add(attr); + } + + const menu = uiState.menu || {}; + for (const attr of Object.keys(menu)) { + if (menu[attr]) attributesToRegister.add(attr); + } + + const toggle = uiState.toggle || {}; + for (const attr of Object.keys(toggle)) { + if (toggle[attr]) attributesToRegister.add(attr); + } + + for (const attribute of attributesToRegister) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + // Skip if attribute is already registered in any facet type + const alreadyRegistered = + params.isHierarchicalFacet(attribute) || + params.isDisjunctiveFacet(attribute) || + params.isConjunctiveFacet(attribute); + + switch (desc.type) { + case 'refinementList': { + const isDisjunctive = (desc.operator ?? 'or') === 'or'; + const values = uiState.refinementList?.[attribute]; + if (!alreadyRegistered) { + if (isDisjunctive) { + params = params.addDisjunctiveFacet(attribute); + } else { + params = params.addFacet(attribute); + } + } + if (values) { + for (const v of values) { + if (isDisjunctive) { + params = params.addDisjunctiveFacetRefinement( + attribute, + v + ); + } else { + params = params.addFacetRefinement(attribute, v); + } + } + } + break; + } + case 'menu': { + if (!alreadyRegistered) { + params = params.addHierarchicalFacet({ + name: attribute, + attributes: [attribute], + }); + } + const menuValue = uiState.menu?.[attribute]; + if (menuValue) { + // Clear any existing refinement first (throws if already refined) + params = params + .removeHierarchicalFacetRefinement(attribute) + .addHierarchicalFacetRefinement(attribute, menuValue); + } + break; + } + case 'hierarchicalMenu': { + const hierDesc = desc as HierarchicalMenuDescriptor; + const hierName = hierDesc.attributes[0]; + const hierAlreadyRegistered = + params.isHierarchicalFacet(hierName) || + params.isDisjunctiveFacet(hierName) || + params.isConjunctiveFacet(hierName); + if (!hierAlreadyRegistered) { + params = params.addHierarchicalFacet({ + name: hierName, + attributes: hierDesc.attributes, + separator: hierDesc.separator ?? ' > ', + rootPath: hierDesc.rootPath ?? null, + showParentLevel: + hierDesc.showParentLevel !== undefined + ? hierDesc.showParentLevel + : true, + }); + } + const hierValue = uiState.hierarchicalMenu?.[hierName]; + if (hierValue && hierValue.length) { + // Clear any existing refinement first + params = params + .removeHierarchicalFacetRefinement(hierName) + .addHierarchicalFacetRefinement( + hierName, + hierValue.join(hierDesc.separator ?? ' > ') + ); + } + break; + } + case 'toggleRefinement': { + if (!alreadyRegistered) { + params = params.addDisjunctiveFacet(attribute); + } + const isRefined = uiState.toggle?.[attribute]; + if (isRefined && desc.on) { + const vals = Array.isArray(desc.on) + ? desc.on + : [String(desc.on)]; + for (const v of vals) { + params = params.addDisjunctiveFacetRefinement(attribute, v); + } + } + break; + } + default: + break; + } + } + + return params; + }, + + // --------------------------------------------------------------- + // UI state: read refinements back from search parameters + // Scans ALL refinements in searchParameters that this widget + // manages (i.e. widgetsFn returns a descriptor for them). + // This is critical for routing: the router calls getWidgetUiState + // on every helper state change, which can happen before + // getWidgetRenderState has populated resolvedDescriptors. + // --------------------------------------------------------------- + getWidgetUiState(uiState, { searchParameters }) { + const result = { ...uiState }; + + // Collect all attributes that have refinements in searchParameters + // and that this widget manages. + + // 1) Disjunctive facet refinements → refinementList or toggle + const disjunctiveFacets = searchParameters.disjunctiveFacets || []; + for (const attribute of disjunctiveFacets) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + const refinements = + searchParameters.getDisjunctiveRefinements(attribute); + if (!refinements.length) continue; + + if (desc.type === 'toggleRefinement') { + result.toggle = { + ...result.toggle, + [attribute]: true, + }; + } else { + // refinementList (disjunctive) + result.refinementList = { + ...result.refinementList, + [attribute]: refinements, + }; + } + } + + // 2) Conjunctive facet refinements → refinementList (and) + const conjunctiveFacets = searchParameters.facets || []; + for (const attribute of conjunctiveFacets) { + // Skip the wildcard '*' facet + if (attribute === '*') continue; + + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + const refinements = + searchParameters.getConjunctiveRefinements(attribute); + if (!refinements.length) continue; + + result.refinementList = { + ...result.refinementList, + [attribute]: refinements, + }; + } + + // 3) Hierarchical facet refinements → menu or hierarchicalMenu + const hierarchicalFacets = searchParameters.hierarchicalFacets || []; + for (const hf of hierarchicalFacets) { + const attribute = typeof hf === 'string' ? hf : hf.name; + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + try { + const hier = + searchParameters.getHierarchicalRefinement(attribute); + if (hier.length) { + if (desc.type === 'menu') { + result.menu = { + ...result.menu, + [attribute]: hier[0], + }; + } else if (desc.type === 'hierarchicalMenu') { + const separator = + (desc as HierarchicalMenuDescriptor).separator ?? ' > '; + result.hierarchicalMenu = { + ...result.hierarchicalMenu, + [attribute]: hier[0].split(separator), + }; + } + } + } catch { + // not registered yet + } + } + + return result; + }, + + // --------------------------------------------------------------- + // Render state: one loop, all attributes + // --------------------------------------------------------------- + getRenderState(renderState, renderOptions) { + return { + ...renderState, + dynamicFacets: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results, state, helper, createURL }) { + if (!results) { + return { + attributesToRender: [], + facets: {}, + refine: () => {}, + toggleShowMore: () => {}, + createURL: () => '', + widgetParams, + }; + } + + // Determine which attributes to render from facetOrdering + const attributesToRender = transformItems( + results.renderingContent?.facetOrdering?.facets?.order ?? [], + { results } + ); + + // Cache for next getWidgetSearchParameters call so the helper + // registers these as proper facets on the next search cycle. + knownAttributes = attributesToRender; + + warning( + maxValuesPerFacet >= (state.maxValuesPerFacet || 0), + `The maxValuesPerFacet set by dynamic facets (${maxValuesPerFacet}) is smaller than one of the limits set by a widget (${state.maxValuesPerFacet}).` + ); + + // Resolve descriptors and build facet slices in ONE loop + resolvedDescriptors = new Map(); + const facetSlices: Record = {}; + + for (const attribute of attributesToRender) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + resolvedDescriptors.set(attribute, desc); + + const isShowingMore = showMoreState.get(attribute) ?? false; + + let items: RefinementListItem[] = []; + let hierarchicalItems: HierarchicalMenuItem[] | undefined; + let hasExhaustiveItems = true; + + // ----- Hierarchical menu ----- + if (desc.type === 'hierarchicalMenu') { + const hierDesc = desc as HierarchicalMenuDescriptor; + const hierName = hierDesc.attributes[0]; + + // getFacetValues returns undefined if not registered yet + let hierFacetValues: ReturnType; + try { + hierFacetValues = results.getFacetValues(hierName, { + sortBy: ['isRefined:desc', 'count:desc', 'name:asc'], + facetOrdering: true, + }); + } catch { + // ignore + } + + if ( + hierFacetValues && + !Array.isArray(hierFacetValues) && + hierFacetValues.data + ) { + hierarchicalItems = prepareHierarchicalItems( + hierFacetValues.data + ); + } else { + // Raw fallback: build flat list from raw API response + const rawFacets = (results as any)._rawResults?.[0]?.facets?.[ + hierName + ]; + + if (rawFacets && typeof rawFacets === 'object') { + hierarchicalItems = Object.entries( + rawFacets as Record + ) + .map(([name, count]) => ({ + value: name, + label: name, + count: count as number, + isRefined: false, + data: null, + })) + .sort((a, b) => b.count - a.count); + } + } + + facetSlices[attribute] = { + type: desc.type, + attribute, + items: [], + hierarchicalItems: hierarchicalItems ?? [], + canRefine: (hierarchicalItems ?? []).length > 0, + isShowingMore, + canToggleShowMore: false, + hasExhaustiveItems: true, + descriptorComponent: desc.component, + }; + continue; + } + + // ----- Menu (uses hierarchical facet internally, single-select) ----- + if (desc.type === 'menu') { + const limit = desc.limit ?? 10; + const showMoreLimit = desc.showMoreLimit ?? limit; + const currentLimit = isShowingMore ? showMoreLimit : limit; + + // getFacetValues returns undefined if not registered yet + let menuFacetValues: ReturnType; + try { + menuFacetValues = results.getFacetValues(attribute, { + sortBy: ['isRefined:desc', 'count:desc', 'name:asc'], + facetOrdering: true, + }); + } catch { + // ignore + } + + // Menu is registered as hierarchical, so getFacetValues + // returns a tree { data: [...] }, not a flat array. + if ( + menuFacetValues && + !Array.isArray(menuFacetValues) && + menuFacetValues.data + ) { + const facetItems = menuFacetValues.data; + items = facetItems.slice(0, currentLimit).map((v: any) => ({ + value: String(v.escapedValue ?? v.path ?? v.name), + label: String(v.name), + count: v.count, + isRefined: v.isRefined, + })); + hasExhaustiveItems = facetItems.length <= currentLimit; + } else { + // Raw fallback: read from raw API response (flat facet data) + const rawFacets = (results as any)._rawResults?.[0]?.facets?.[ + attribute + ]; + + if (rawFacets && typeof rawFacets === 'object') { + const rawValues = Object.entries( + rawFacets as Record + ) + .map(([name, count]) => ({ + name, + count: count as number, + isRefined: false, + })) + .sort((a, b) => b.count - a.count); + + items = rawValues.slice(0, currentLimit).map((v) => ({ + value: String(v.name), + label: String(v.name), + count: v.count, + isRefined: v.isRefined, + })); + hasExhaustiveItems = rawValues.length <= currentLimit; + } + } + + const canToggleShowMore = + (desc.showMoreLimit ?? 0) > (desc.limit ?? 10) && + (isShowingMore || !hasExhaustiveItems); + + facetSlices[attribute] = { + type: desc.type, + attribute, + items, + canRefine: items.length > 0, + isShowingMore, + canToggleShowMore, + hasExhaustiveItems, + descriptorComponent: desc.component, + }; + continue; + } + + // ----- refinementList / ratingMenu ----- + if (desc.type === 'refinementList' || desc.type === 'ratingMenu') { + const limit = + desc.type === 'refinementList' ? desc.limit ?? 10 : 5; + const showMoreLimit = + desc.type === 'refinementList' + ? desc.showMoreLimit ?? limit + : limit; + const currentLimit = isShowingMore ? showMoreLimit : limit; + + let rawValues: Array<{ + name: string; + count: number; + isRefined: boolean; + }> = []; + + try { + const values = results.getFacetValues(attribute, { + sortBy: ['isRefined:desc', 'count:desc', 'name:asc'], + facetOrdering: true, + }); + + if (values && Array.isArray(values)) { + rawValues = values as Array<{ + name: string; + count: number; + isRefined: boolean; + }>; + } + } catch { + // attribute not registered in the helper yet + } + + // Fallback: read directly from raw API response + if (rawValues.length === 0) { + const rawFacets = + (results as any)._rawResults?.[0]?.facets?.[attribute] ?? + (results as any).facets?.find?.( + (f: any) => f.name === attribute + )?.data; + + if (rawFacets && typeof rawFacets === 'object') { + rawValues = Object.entries( + rawFacets as Record + ) + .map(([name, count]) => ({ + name, + count: count as number, + isRefined: false, + })) + .sort((a, b) => b.count - a.count); + } + } + + items = rawValues.slice(0, currentLimit).map((v) => ({ + value: String(v.name), + label: String(v.name), + count: v.count, + isRefined: v.isRefined, + })); + + hasExhaustiveItems = rawValues.length <= currentLimit; + } + + const canToggleShowMore = + desc.type === 'refinementList' && + (desc.showMoreLimit ?? 0) > (desc.limit ?? 10) && + (isShowingMore || !hasExhaustiveItems); + + facetSlices[attribute] = { + type: desc.type, + attribute, + items, + canRefine: items.length > 0, + isShowingMore, + canToggleShowMore, + hasExhaustiveItems, + descriptorComponent: desc.component, + }; + } + + // Centralized refine action + const refine = (attribute: string, value: string) => { + if (!helper) return; + const desc = resolvedDescriptors.get(attribute); + if (!desc) return; + + // Ensure the facet is registered in the helper state before + // attempting to toggle it. On the first interaction the + // helper may not yet have the attribute registered. + const ensureDisjunctive = (attr: string) => { + if ( + !helper.state.isDisjunctiveFacet(attr) && + !helper.state.isConjunctiveFacet(attr) && + !helper.state.isHierarchicalFacet(attr) + ) { + helper.setState(helper.state.addDisjunctiveFacet(attr)); + } + }; + + switch (desc.type) { + case 'refinementList': { + const isDisjunctive = (desc.operator ?? 'or') === 'or'; + if (isDisjunctive) { + ensureDisjunctive(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + } else { + if (!helper.state.isConjunctiveFacet(attribute)) { + helper.setState(helper.state.addFacet(attribute)); + } + helper.toggleFacetRefinement(attribute, value).search(); + } + break; + } + case 'menu': { + try { + if (!helper.state.isHierarchicalFacet(attribute)) { + helper.setState( + helper.state.addHierarchicalFacet({ + name: attribute, + attributes: [attribute], + }) + ); + } + const current = + helper.getHierarchicalFacetBreadcrumb(attribute); + if (current[0] === value) { + helper + .removeHierarchicalFacetRefinement(attribute) + .search(); + } else { + helper + .removeHierarchicalFacetRefinement(attribute) + .addHierarchicalFacetRefinement(attribute, value) + .search(); + } + } catch { + ensureDisjunctive(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + } + break; + } + case 'hierarchicalMenu': { + const hierDesc = desc as HierarchicalMenuDescriptor; + const hierName = hierDesc.attributes[0]; + try { + if (!helper.state.isHierarchicalFacet(hierName)) { + helper.setState( + helper.state.addHierarchicalFacet({ + name: hierName, + attributes: hierDesc.attributes, + separator: hierDesc.separator ?? ' > ', + rootPath: hierDesc.rootPath ?? null, + showParentLevel: + hierDesc.showParentLevel !== undefined + ? hierDesc.showParentLevel + : true, + }) + ); + } + helper.toggleFacetRefinement(hierName, value).search(); + } catch { + // fallback + } + break; + } + case 'toggleRefinement': { + ensureDisjunctive(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + break; + } + default: { + ensureDisjunctive(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + break; + } + } + }; + + // Centralized showMore toggle + const toggleShowMore = (attribute: string) => { + const current = showMoreState.get(attribute) ?? false; + showMoreState.set(attribute, !current); + // Trigger re-render + renderFn( + { + ...this.getWidgetRenderState({ + results, + state, + helper, + createURL, + } as any), + instantSearchInstance: (helper as any) + ._lastDerivedHelperSearchParameters?.instantSearchInstance, + }, + false + ); + }; + + // Centralized createURL + const createURLFn = (attribute: string, value: string) => { + if (!createURL) return '#'; + return createURL((uiState: any) => { + const refinementList = { + ...(uiState.refinementList || {}), + }; + const current = refinementList[attribute] || []; + if (current.includes(value)) { + refinementList[attribute] = current.filter( + (v: string) => v !== value + ); + } else { + refinementList[attribute] = [...current, value]; + } + return { ...uiState, refinementList }; + }); + }; + + return { + attributesToRender, + facets: facetSlices, + refine, + toggleShowMore, + createURL: createURLFn, + widgetParams, + }; + }, + }; + }; + }; + +export default connectDynamicFacets; diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index fb08e3cd87c..e10ecd19e16 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -23,6 +23,8 @@ export const EXPERIMENTAL_connectDynamicWidgets = deprecate( ); export { connectDynamicWidgets }; +export { default as connectDynamicFacets } from './dynamic-facets/connectDynamicFacets'; +export { default as connectDynamicFacetsComposed } from './dynamic-facets-composed/connectDynamicFacetsComposed'; export { default as connectClearRefinements } from './clear-refinements/connectClearRefinements'; export { default as connectCurrentRefinements } from './current-refinements/connectCurrentRefinements'; diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgetsV2.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgetsV2.tsx new file mode 100644 index 00000000000..6e439989f49 --- /dev/null +++ b/packages/react-instantsearch-core/src/components/DynamicWidgetsV2.tsx @@ -0,0 +1,380 @@ +import React, { Fragment } from 'react'; + +import { useDynamicFacets } from '../connectors/useDynamicFacets'; +import { invariant } from '../lib/invariant'; + +import type { + DynamicFacetsConnectorParams, + FacetSlice, + WidgetDescriptor, +} from 'instantsearch.js/es/connectors/dynamic-facets/connectDynamicFacets'; +import type { ReactElement, ComponentType, ReactNode } from 'react'; + +// --------------------------------------------------------------------------- +// The component props received by every renderer (built-in or custom) +// --------------------------------------------------------------------------- + +export type DynamicFacetComponentProps = { + attribute: string; + slice: FacetSlice; + refine: (value: string) => void; + toggleShowMore: () => void; + createURL: (value: string) => string; +}; + +// --------------------------------------------------------------------------- +// Built-in default renderers +// These render the correct ais-* BEM class names directly, so +// instantsearch.css themes (satellite, etc.) work out of the box. +// This avoids a circular dependency on react-instantsearch's UI components. +// --------------------------------------------------------------------------- + +function DefaultRefinementList({ + attribute, + slice, + refine, + toggleShowMore, +}: DynamicFacetComponentProps) { + return ( +
+
{attribute}
+
+
+
    + {slice.items.map((item) => ( +
  • + +
  • + ))} +
+ {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +function DefaultMenu({ + attribute, + slice, + refine, + toggleShowMore, + createURL, +}: DynamicFacetComponentProps) { + return ( +
+
{attribute}
+
+
+ + {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +function HierarchicalList({ + items, + refine, + createURL, + isChild, +}: { + items: NonNullable; + refine: (value: string) => void; + createURL: (value: string) => string; + isChild?: boolean; +}) { + if (items.length === 0) return null; + return ( + + ); +} + +function DefaultHierarchicalMenu({ + attribute, + slice, + refine, + toggleShowMore, + createURL, +}: DynamicFacetComponentProps) { + const items = slice.hierarchicalItems ?? []; + return ( +
+
{attribute}
+
+
+ + {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +const BUILT_IN_RENDERERS: Record< + string, + ComponentType +> = { + refinementList: DefaultRefinementList, + menu: DefaultMenu, + hierarchicalMenu: DefaultHierarchicalMenu, +}; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type DynamicWidgetsV2Props = Omit< + DynamicFacetsConnectorParams, + 'widgets' +> & { + /** + * Maps each attribute from facetOrdering to a widget descriptor. + * Return `false` to skip an attribute. + */ + widgets: (attribute: string) => WidgetDescriptor | false; + + /** + * Per-type component overrides. Keys are widget types, values are React + * components that receive DynamicFacetComponentProps. + */ + components?: Partial< + Record> + >; + + /** + * Ultimate escape-hatch renderer. Called when no built-in renderer, + * per-type override, or per-attribute component matches. + */ + fallbackComponent?: ComponentType; + + children?: ReactNode; +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function DynamicWidgetsV2({ + children, + fallbackComponent: Fallback, + components: componentOverrides = {}, + widgets: widgetsFn, + ...props +}: DynamicWidgetsV2Props) { + const { attributesToRender, facets, refine, toggleShowMore, createURL } = + useDynamicFacets( + { widgets: widgetsFn, ...props }, + { $$widgetType: 'ais.dynamicFacets' } + ); + + // Map explicit children by attribute (backward compat / hybrid mode) + const explicitWidgets: Map = new Map(); + + React.Children.forEach(children, (child) => { + const attribute = getWidgetAttribute(child); + if (attribute !== undefined) { + explicitWidgets.set(attribute, child); + } + }); + + return ( + <> + {attributesToRender.map((attribute) => { + // Explicit child takes precedence (it registers its own connector) + if (explicitWidgets.has(attribute)) { + return ( + + {explicitWidgets.get(attribute)} + + ); + } + + // Render through the shared store + const slice = facets[attribute]; + if (!slice) return null; + + const facetProps: DynamicFacetComponentProps = { + attribute, + slice, + refine: (value: string) => refine(attribute, value), + toggleShowMore: () => toggleShowMore(attribute), + createURL: (value: string) => createURL(attribute, value), + }; + + // Resolution chain: descriptor.component > components[type] > built-in > fallback + const Component = + slice.descriptorComponent ?? + componentOverrides[slice.type] ?? + BUILT_IN_RENDERERS[slice.type] ?? + Fallback; + + if (!Component) return null; + + return ( + + + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Utility: extract attribute from a React child element +// --------------------------------------------------------------------------- + +function isReactElement( + element: any +): element is ReactElement> { + return typeof element === 'object' && element.props; +} + +function getWidgetAttribute(element: ReactNode): string | undefined { + if (!isReactElement(element)) { + return undefined; + } + + if (element.props.attribute) { + return element.props.attribute; + } + + if (Array.isArray(element.props.attributes)) { + return element.props.attributes[0]; + } + + if (element.props.children) { + invariant( + React.Children.count(element.props.children) === 1, + ` only supports a single component in nested components.` + ); + + return getWidgetAttribute(React.Children.only(element.props.children)); + } + + return undefined; +} diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgetsV2Composed.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgetsV2Composed.tsx new file mode 100644 index 00000000000..70f7b0a26db --- /dev/null +++ b/packages/react-instantsearch-core/src/components/DynamicWidgetsV2Composed.tsx @@ -0,0 +1,367 @@ +import React, { Fragment } from 'react'; + +import { useDynamicFacetsComposed } from '../connectors/useDynamicFacetsComposed'; +import { invariant } from '../lib/invariant'; + +import type { + DynamicFacetsComposedConnectorParams, + FacetSlice, + WidgetDescriptor, +} from 'instantsearch.js/es/connectors/dynamic-facets-composed/connectDynamicFacetsComposed'; +import type { ReactElement, ComponentType, ReactNode } from 'react'; + +// Re-export the props type under the `Composed` name for external use +export type DynamicFacetComposedComponentProps = { + attribute: string; + slice: FacetSlice; + refine: (value: string) => void; + toggleShowMore: () => void; + createURL: (value: string) => string; +}; + +// --------------------------------------------------------------------------- +// Built-in default renderers +// Inline JSX with ais-* BEM class names — avoids circular dependency on +// react-instantsearch's UI components while working with instantsearch.css. +// --------------------------------------------------------------------------- + +function DefaultRefinementList({ + attribute, + slice, + refine, + toggleShowMore, +}: DynamicFacetComposedComponentProps) { + return ( +
+
{attribute}
+
+
+
    + {slice.items.map((item) => ( +
  • + +
  • + ))} +
+ {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +function DefaultMenu({ + attribute, + slice, + refine, + toggleShowMore, + createURL, +}: DynamicFacetComposedComponentProps) { + return ( +
+
{attribute}
+
+
+ + {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +function ComposedHierarchicalList({ + items, + refine, + createURL, + isChild, +}: { + items: NonNullable; + refine: (value: string) => void; + createURL: (value: string) => string; + isChild?: boolean; +}) { + if (items.length === 0) return null; + return ( + + ); +} + +function DefaultHierarchicalMenu({ + attribute, + slice, + refine, + toggleShowMore, + createURL, +}: DynamicFacetComposedComponentProps) { + const items = slice.hierarchicalItems ?? []; + return ( +
+
{attribute}
+
+
+ + {(slice.canToggleShowMore || slice.isShowingMore) && ( + + )} +
+
+
+ ); +} + +const BUILT_IN_RENDERERS: Record< + string, + ComponentType +> = { + refinementList: DefaultRefinementList, + menu: DefaultMenu, + hierarchicalMenu: DefaultHierarchicalMenu, +}; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type DynamicWidgetsV2ComposedProps = Omit< + DynamicFacetsComposedConnectorParams, + 'widgets' +> & { + widgets: (attribute: string) => WidgetDescriptor | false; + + /** + * Per-type component overrides. + */ + components?: Partial< + Record> + >; + + /** + * Ultimate escape-hatch renderer. + */ + fallbackComponent?: ComponentType; + + children?: ReactNode; +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function DynamicWidgetsV2Composed({ + children, + fallbackComponent: Fallback, + components: componentOverrides = {}, + widgets: widgetsFn, + ...props +}: DynamicWidgetsV2ComposedProps) { + const { attributesToRender, facets, refine, toggleShowMore, createURL } = + useDynamicFacetsComposed( + { widgets: widgetsFn, ...props }, + { $$widgetType: 'ais.dynamicFacetsComposed' } + ); + + const explicitWidgets: Map = new Map(); + + React.Children.forEach(children, (child) => { + const attribute = getWidgetAttribute(child); + if (attribute !== undefined) { + explicitWidgets.set(attribute, child); + } + }); + + return ( + <> + {attributesToRender.map((attribute) => { + if (explicitWidgets.has(attribute)) { + return ( + + {explicitWidgets.get(attribute)} + + ); + } + + const slice = facets[attribute]; + if (!slice) return null; + + const facetProps: DynamicFacetComposedComponentProps = { + attribute, + slice, + refine: (value: string) => refine(attribute, value), + toggleShowMore: () => toggleShowMore(attribute), + createURL: (value: string) => createURL(attribute, value), + }; + + // Resolution chain: descriptor.component > components[type] > built-in > fallback + const Component = + slice.descriptorComponent ?? + componentOverrides[slice.type] ?? + BUILT_IN_RENDERERS[slice.type] ?? + Fallback; + + if (!Component) return null; + + return ( + + + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Utility: extract attribute from a React child element +// --------------------------------------------------------------------------- + +function isReactElement( + element: any +): element is ReactElement> { + return typeof element === 'object' && element.props; +} + +function getWidgetAttribute(element: ReactNode): string | undefined { + if (!isReactElement(element)) { + return undefined; + } + + if (element.props.attribute) { + return element.props.attribute; + } + + if (Array.isArray(element.props.attributes)) { + return element.props.attributes[0]; + } + + if (element.props.children) { + invariant( + React.Children.count(element.props.children) === 1, + ` only supports a single component in nested components.` + ); + + return getWidgetAttribute(React.Children.only(element.props.children)); + } + + return undefined; +} diff --git a/packages/react-instantsearch-core/src/connectors/useDynamicFacets.ts b/packages/react-instantsearch-core/src/connectors/useDynamicFacets.ts new file mode 100644 index 00000000000..0b78122139f --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useDynamicFacets.ts @@ -0,0 +1,21 @@ +import connectDynamicFacets from 'instantsearch.js/es/connectors/dynamic-facets/connectDynamicFacets'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + DynamicFacetsConnectorParams, + DynamicFacetsWidgetDescription, +} from 'instantsearch.js/es/connectors/dynamic-facets/connectDynamicFacets'; + +export type UseDynamicFacetsProps = DynamicFacetsConnectorParams; + +export function useDynamicFacets( + props: UseDynamicFacetsProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector< + DynamicFacetsConnectorParams, + DynamicFacetsWidgetDescription + >(connectDynamicFacets, props, additionalWidgetProperties); +} diff --git a/packages/react-instantsearch-core/src/connectors/useDynamicFacetsComposed.ts b/packages/react-instantsearch-core/src/connectors/useDynamicFacetsComposed.ts new file mode 100644 index 00000000000..4f920e14ab7 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useDynamicFacetsComposed.ts @@ -0,0 +1,22 @@ +import connectDynamicFacetsComposed from 'instantsearch.js/es/connectors/dynamic-facets-composed/connectDynamicFacetsComposed'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + DynamicFacetsComposedConnectorParams, + DynamicFacetsComposedWidgetDescription, +} from 'instantsearch.js/es/connectors/dynamic-facets-composed/connectDynamicFacetsComposed'; + +export type UseDynamicFacetsComposedProps = + DynamicFacetsComposedConnectorParams; + +export function useDynamicFacetsComposed( + props: UseDynamicFacetsComposedProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector< + DynamicFacetsComposedConnectorParams, + DynamicFacetsComposedWidgetDescription + >(connectDynamicFacetsComposed, props, additionalWidgetProperties); +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index 1b572b0c517..5f88fc66523 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -12,6 +12,10 @@ export * from './connectors/useClearRefinements'; export * from './connectors/useConfigure'; export * from './connectors/useCurrentRefinements'; export * from './connectors/useDynamicWidgets'; +export * from './connectors/useDynamicFacets'; +export * from './connectors/useDynamicFacetsComposed'; +export * from './components/DynamicWidgetsV2'; +export * from './components/DynamicWidgetsV2Composed'; export * from './connectors/useFrequentlyBoughtTogether'; export * from './connectors/useGeoSearch'; export * from './connectors/useHierarchicalMenu'; diff --git a/specs/src/pages/rfc-dynamic-widgets-v2.md b/specs/src/pages/rfc-dynamic-widgets-v2.md new file mode 100644 index 00000000000..326e5160053 --- /dev/null +++ b/specs/src/pages/rfc-dynamic-widgets-v2.md @@ -0,0 +1,943 @@ +# RFC: DynamicWidgets v2 — Shared Facet State Architecture + +**Status:** Draft (with working PoC) + +**Authors:** Haroen Viaene (and Claude Opus 4.6) + +**Date:** 2026-03-18 + +**Related:** [PR #6930](https://github.com/algolia/instantsearch/pull/6930), [PR #6929](https://github.com/algolia/instantsearch/pull/6929) + +**PoC:** `examples/react/dynamic-widgets-v2/` + +--- + +## Summary + +DynamicWidgets v2 replaces the current "one widget per dynamic facet" model with a single shared facet store that serves many lightweight renderers. The goal is to scale from the current practical limit of ~20–50 dynamic facets to hundreds, without application slowdowns or crashes, while preserving full interactivity (refinement, showMore, routing). + +## Motivation + +### The problem + +When `DynamicWidgets` renders N facets today, it mounts N independent widget instances. Each widget: + +1. **Registers search parameters** via `getWidgetSearchParameters` — adding a facet/disjunctiveFacet/hierarchicalFacet to the helper state. +2. **Triggers a search** when added — `addWidgets` calls `instantSearchInstance.scheduleSearch()`. +3. **Computes its own render state** via `getWidgetRenderState` — reading from `SearchResults`, formatting items, creating action closures. +4. **Manages its own lifecycle** — `init`, `render`, `dispose`, with GC pressure from closures and event listeners. + +At 200+ facets, this causes: + +- **O(N) widget registrations** in the Index widget's `localWidgets` array. +- **O(N) `getWidgetSearchParameters` calls** per search parameter rebuild, each mutating a `SearchParameters` object via immutable copies. +- **O(N) connector computation** per render cycle. +- **O(N) React reconciliation** for `useConnector` hook instances. +- **Extra network requests** when widgets mount progressively. + +### What we tried in PR #6930 + +1. **`mode="batched"`** — renders all facets through a single fallback component, bypassing per-widget mounting entirely. Fast, but loses all widget behavior (refinement, searchForFacetValues, showMore, routing). +2. **Progressive/idle mounting** — mounts widgets in chunks via `requestIdleCallback`. Helps initial render, but each chunk triggers another search, and the total cost is unchanged once all widgets are mounted. + +### Why incremental fixes aren't enough + +The fundamental issue is architectural: the widget interface (`init` / `render` / `dispose` / `getWidgetSearchParameters`) was designed assuming widgets are few and long-lived. DynamicWidgets subverts this assumption by making widget count data-driven and potentially unbounded. + +## Design Principles + +1. **Facet state is computed once, consumed many times.** One store, many views. +2. **Widget count should not affect search parameter count.** Facets should be registered declaratively by the store, not imperatively by each widget. +3. **Interactive behavior (refine, showMore) must be preserved.** +4. **Backward compatibility.** Existing `DynamicWidgets` users should not break. +5. **Framework-agnostic at the connector level.** The solution must work for React, Vue, and vanilla JS. + +## Proposed Architecture + +### Overview + +``` +┌──────────────────────────────────────────────────────┐ +│ DynamicWidgets v2 │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ connectDynamicFacets (connector) │ │ +│ │ │ │ +│ │ • ONE widget registration in the Index │ │ +│ │ • Registers facets: ['*'] (or explicit list) │ │ +│ │ • Reads facetOrdering from results │ │ +│ │ • Builds normalized facet store from results │ │ +│ │ • Exposes refinement actions │ │ +│ └──────────────┬─────────────────────────────────┘ │ +│ │ │ +│ Facet store (render state) │ +│ { │ +│ attributesToRender: string[] │ +│ facets: Record │ +│ refine(attribute, value): void │ +│ toggleShowMore(attribute): void │ +│ createURL(attribute, value): string │ +│ } │ +│ │ │ +│ ┌─────────────┼──────────────┐ │ +│ ▼ ▼ ▼ │ +│ FacetView FacetView FacetView │ +│ (brand) (color) (category) │ +│ │ +│ Pure rendering components. No widget registration. │ +│ Receive their slice of state + bound action refs. │ +└──────────────────────────────────────────────────────┘ +``` + +### 1. New connector: `connectDynamicFacets` + +A new connector in `packages/instantsearch.js/src/connectors/dynamic-facets/`. This is **one widget** registered with the Index, regardless of how many facets are displayed. + +#### Attribute-to-widget mapping: the `widgets` function + +The connector needs to know _how_ each attribute should be faceted. Today this is implicit in which widget is used. In v2, this mapping is **explicit and declarative** via a `widgets` function: + +```ts +type DynamicFacetsConnectorParams = { + widgets: (attribute: string) => WidgetDescriptor | false; + facets?: ['*'] | string[]; + maxValuesPerFacet?: number; + transformItems?: TransformItems; +}; +``` + +##### Widget descriptors (discriminated union) + +```ts +type WidgetDescriptor = + | RefinementListDescriptor // disjunctive/conjunctive facet + | MenuDescriptor // single-select hierarchical + | HierarchicalMenuDescriptor // multi-level hierarchical + | ToggleRefinementDescriptor // boolean toggle + | NumericMenuDescriptor // numeric range buckets + | RangeDescriptor // continuous numeric range + | RatingMenuDescriptor; // star rating + +type RefinementListDescriptor = { + type: 'refinementList'; + operator?: 'and' | 'or'; + limit?: number; + showMoreLimit?: number; + searchable?: boolean; +}; + +type MenuDescriptor = { + type: 'menu'; + limit?: number; + showMoreLimit?: number; +}; + +type HierarchicalMenuDescriptor = { + type: 'hierarchicalMenu'; + attributes: string[]; + separator?: string; + rootPath?: string | null; + showParentLevel?: boolean; + limit?: number; + showMoreLimit?: number; +}; + +type ToggleRefinementDescriptor = { + type: 'toggleRefinement'; + on?: string | string[] | boolean; + off?: string | string[] | boolean; +}; + +type NumericMenuDescriptor = { + type: 'numericMenu'; + items: Array<{ label: string; start?: number; end?: number }>; +}; + +type RangeDescriptor = { + type: 'range'; + min?: number; + max?: number; + precision?: number; +}; + +type RatingMenuDescriptor = { + type: 'ratingMenu'; + max?: number; +}; +``` + +##### Usage examples + +```tsx +// Simplest case — everything is a RefinementList + ({ type: 'refinementList' })} + fallbackComponent={FacetRenderer} +/> + +// Mixed widget types + { + if (attribute === 'hierarchicalCategories.lvl1') + return { + type: 'hierarchicalMenu', + attributes: ['hierarchicalCategories.lvl1', 'hierarchicalCategories.lvl2', 'hierarchicalCategories.lvl3'], + }; + if (attribute === 'brand') + return { type: 'menu', limit: 5, showMoreLimit: 20 }; + return { type: 'refinementList', limit: 5, showMoreLimit: 20 }; + }} + fallbackComponent={FacetRenderer} +/> +``` + +##### Why a function instead of a static map? + +1. **Handles unknown attributes.** `facetOrdering` can return new attributes. A function always has a default path. +2. **Allows dynamic logic.** The mapping can depend on runtime state, feature flags, server config. +3. **Composable.** `widgets={(attr) => serverConfig[attr] ?? localOverrides[attr] ?? defaultWidget}` + +#### The two-search bootstrapping problem + +A key challenge discovered during PoC: the connector can't know which attributes to register until it receives the first search results (which contain `facetOrdering`). This creates a bootstrapping problem: + +- **`refinementList`** works with `facets: ['*']` on the first search because the raw API response includes flat facet values for all attributes. +- **`menu`** and **`hierarchicalMenu`** require explicit `addHierarchicalFacet()` registration, otherwise `getFacetValues()` returns `undefined`. + +**Solution: two-phase bootstrapping with raw fallback** + +1. **First search:** Only `facets: ['*']` is sent. The connector discovers attributes from `facetOrdering` and uses raw API response data (`results._rawResults[0].facets[attribute]`) as a fallback to show flat values immediately. +2. **`render()` detects unregistered facets:** After `knownAttributes` is populated, `render()` checks if any attributes need proper registration. If so, it calls `helper.setState(params)` + `scheduleSearch()`. +3. **Second search:** The helper now has proper hierarchical facet registrations. `getFacetValues()` returns correct tree structures. + +The guard `!params.isHierarchicalFacet(attr)` prevents infinite loops — re-search only happens when facets are genuinely not yet registered. + +```ts +render(renderOptions) { + const renderState = this.getWidgetRenderState(renderOptions); + const { helper } = renderOptions; + + if (helper && knownAttributes.length > 0) { + let needsReSearch = false; + let params = helper.state; + + for (const attr of knownAttributes) { + const desc = resolveDescriptor(widgetsFn, attr); + if (!desc) continue; + switch (desc.type) { + case 'menu': + if (!params.isHierarchicalFacet(attr)) { + params = params.addHierarchicalFacet({ name: attr, attributes: [attr] }); + needsReSearch = true; + } + break; + case 'hierarchicalMenu': + if (!params.isHierarchicalFacet(desc.attributes[0])) { + params = params.addHierarchicalFacet({ + name: desc.attributes[0], + attributes: desc.attributes, + separator: desc.separator ?? ' > ', + }); + needsReSearch = true; + } + break; + // ... + } + } + if (needsReSearch) { + helper.setState(params); + renderOptions.instantSearchInstance.scheduleSearch(); + } + } + renderFn({ ...renderState, instantSearchInstance }, false); +} +``` + +**Note:** `getWidgetSearchParameters` is only called during `addWidgets`/`init`, not after `render()`. This is why `render()` must directly mutate the helper state. A future optimization could pre-register all facets if the attribute list is known ahead of time. + +#### Search parameter registration + +`getWidgetSearchParameters` registers search parameters for ALL managed attributes in one pass. It also reads from `uiState` to restore refinements from the URL (routing): + +```ts +getWidgetSearchParameters(searchParameters, { uiState }) { + let params = searchParameters; + + // 1. Global facet request (discovery) + for (const facet of facets) { + params = params.addFacet(facet); + } + params = params.setQueryParameter('maxValuesPerFacet', + Math.max(maxValuesPerFacet || 0, params.maxValuesPerFacet || 0)); + + // 2. Merge known attributes + refined attributes from uiState + const attributesToRegister = new Set(knownAttributes); + Object.keys(uiState.refinementList || {}).forEach(attr => attributesToRegister.add(attr)); + Object.keys(uiState.menu || {}).forEach(attr => attributesToRegister.add(attr)); + Object.keys(uiState.toggle || {}).forEach(attr => attributesToRegister.add(attr)); + + // 3. Register each attribute according to its descriptor type + for (const attribute of attributesToRegister) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + + // Guard: skip if already registered + const alreadyRegistered = params.isHierarchicalFacet(attribute) + || params.isDisjunctiveFacet(attribute) || params.isConjunctiveFacet(attribute); + + switch (desc.type) { + case 'refinementList': { + const isDisjunctive = (desc.operator ?? 'or') === 'or'; + if (!alreadyRegistered) { + params = isDisjunctive ? params.addDisjunctiveFacet(attribute) : params.addFacet(attribute); + } + // Apply refinements from uiState... + break; + } + case 'menu': { + if (!alreadyRegistered) { + params = params.addHierarchicalFacet({ name: attribute, attributes: [attribute] }); + } + // Apply refinement from uiState.menu... + break; + } + case 'hierarchicalMenu': { + if (!params.isHierarchicalFacet(desc.attributes[0])) { + params = params.addHierarchicalFacet({ + name: desc.attributes[0], attributes: desc.attributes, + separator: desc.separator ?? ' > ', + }); + } + // Apply refinement from uiState.hierarchicalMenu... + break; + } + case 'toggleRefinement': { + if (!alreadyRegistered) params = params.addDisjunctiveFacet(attribute); + break; + } + } + } + return params; +} +``` + +#### Helper data format transformation + +The helper's `getFacetValues()` returns items in internal format (`{ name, escapedValue, path, data }`). InstantSearch UI components expect `{ label, value, data }`. The connector transforms recursively: + +```ts +function prepareHierarchicalItems(facetValues: any[]): HierarchicalMenuItem[] { + return facetValues.map(({ name, escapedValue, data, path, ...rest }) => ({ + ...rest, + label: String(name), + value: String(escapedValue ?? path ?? name), + data: Array.isArray(data) ? prepareHierarchicalItems(data) : null, + })); +} +``` + +This applies to both `hierarchicalMenu` and `menu` items (menu is hierarchical internally in the helper). + +#### Normalized render state + +```ts +type FacetSlice = { + type: WidgetDescriptor['type']; + attribute: string; + items: RefinementListItem[]; // flat items (refinementList, menu) + hierarchicalItems?: HierarchicalMenuItem[]; // tree items (hierarchicalMenu) + canRefine: boolean; + isShowingMore: boolean; + canToggleShowMore: boolean; + hasExhaustiveItems: boolean; +}; + +type DynamicFacetsRenderState = { + attributesToRender: string[]; + facets: Record; + refine: (attribute: string, value: string) => void; + toggleShowMore: (attribute: string) => void; + createURL: (attribute: string, value: string) => string; +}; +``` + +`getWidgetRenderState` iterates `attributesToRender` once, building all slices. For each attribute it: + +1. Resolves the descriptor via `widgetsFn(attribute)` +2. Tries `results.getFacetValues()` for processed data +3. Falls back to `results._rawResults[0].facets[attribute]` if not yet registered +4. Transforms hierarchical data via `prepareHierarchicalItems()` +5. Applies `limit`/`showMoreLimit` slicing + +#### Refinement dispatch + +Centralized, dispatching based on descriptor type: + +```ts +const refine = (attribute: string, value: string) => { + const desc = resolvedDescriptors.get(attribute); + switch (desc.type) { + case 'refinementList': + ensureRegistered(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + break; + case 'menu': + ensureHierarchical(attribute); + // Single-select: remove then add + helper + .removeHierarchicalFacetRefinement(attribute) + .addHierarchicalFacetRefinement(attribute, value) + .search(); + break; + case 'hierarchicalMenu': + ensureHierarchical(desc.attributes[0]); + helper.toggleFacetRefinement(desc.attributes[0], value).search(); + break; + case 'toggleRefinement': + ensureRegistered(attribute); + helper.toggleFacetRefinement(attribute, value).search(); + break; + } +}; +``` + +### 2. UI State and Routing + +`getWidgetUiState` scans `searchParameters` directly (not the render-time cache), because it's called on every helper `change` event — potentially before rendering. + +| Descriptor type | uiState key | +| ------------------ | ------------------------ | +| `refinementList` | `refinementList[attr]` | +| `menu` | `menu[attr]` | +| `hierarchicalMenu` | `hierarchicalMenu[attr]` | +| `toggleRefinement` | `toggle[attr]` | + +The same keys used by standalone widgets, ensuring routing compatibility. + +### 3. React layer + +**`useDynamicFacets` hook** — wraps `connectDynamicFacets` via `useConnector`. One call = one widget = one search. + +**`` component:** + +```tsx +export function DynamicWidgetsV2({ + children, + fallbackComponent: Fallback, + widgets, + ...props +}) { + const { attributesToRender, facets, refine, toggleShowMore, createURL } = + useDynamicFacets({ widgets, ...props }); + + const explicitWidgets = new Map(); + React.Children.forEach(children, (child) => { + const attr = getWidgetAttribute(child); + if (attr) explicitWidgets.set(attr, child); + }); + + return ( + <> + {attributesToRender.map((attribute) => { + if (explicitWidgets.has(attribute)) + return ( + + {explicitWidgets.get(attribute)} + + ); + const slice = facets[attribute]; + if (!slice) return null; + return ( + + refine(attribute, v)} + toggleShowMore={() => toggleShowMore(attribute)} + createURL={(v) => createURL(attribute, v)} + /> + + ); + })} + + ); +} +``` + +The `fallbackComponent` receives `DynamicFacetComponentProps` and switches on `slice.type` to render the appropriate UI. It can reuse existing UI components (`RefinementListUi`, `MenuUi`, `HierarchicalMenuUi`). + +### 4. Unified rendering: eliminating the double-declaration problem + +The PoC API requires users to declare intent twice: + +1. **In `widgets`:** "brand is a menu" — `{ type: 'menu', limit: 5 }` +2. **In `fallbackComponent`:** "if it's a menu, render MenuUi" — `switch (slice.type) { case 'menu': ... }` + +The `switch` in `fallbackComponent` always mirrors what `widgets` already declared. This is boilerplate that the library should handle. + +#### Built-in default renderers + +The component ships with built-in renderers for every known `type`. When `slice.type === 'refinementList'`, the component uses the existing `RefinementListUi` automatically — no `fallbackComponent` needed for the common case. + +```ts +// Internal: default renderer map +const BUILT_IN_RENDERERS: Record< + string, + ComponentType +> = { + refinementList: DefaultRefinementList, // wraps RefinementListUi + menu: DefaultMenu, // wraps MenuUi + hierarchicalMenu: DefaultHierarchicalMenu, // wraps HierarchicalMenuUi + toggleRefinement: DefaultToggle, // wraps ToggleRefinementUi +}; +``` + +#### The `components` prop: per-type override + +A new optional `components` prop lets users replace the renderer for an entire widget type: + +```tsx + +``` + +#### Per-attribute `component` in the descriptor + +The descriptor itself can carry a renderer, so a specific attribute gets a custom component without affecting all other attributes of the same type: + +```tsx +widgets={(attr) => { + if (attr === 'brand') return { + type: 'menu', limit: 5, + component: BrandSelector, // only brand uses this + }; + return { type: 'refinementList' }; +}} +``` + +#### Resolution chain + +Renderer resolution follows a 4-level priority: + +1. **`descriptor.component`** — per-attribute override from the `widgets` function +2. **`components[type]`** — per-type override from the `components` prop +3. **Built-in default** — library-provided renderer using existing UI components +4. **`fallbackComponent`** — ultimate escape hatch for unknown types or total control + +#### Updated component API + +```tsx +type DynamicWidgetsV2Props = { + // Declares facet types (connector-level, framework-agnostic) + widgets: (attribute: string) => WidgetDescriptor | false; + facets?: ['*'] | string[]; + maxValuesPerFacet?: number; + transformItems?: TransformItems; + + // Rendering customization (React-level) + components?: Partial< + Record> + >; + fallbackComponent?: ComponentType; + children?: React.ReactNode; // explicit children still win +}; +``` + +#### Updated component implementation + +```tsx +export function DynamicWidgetsV2({ + children, + fallbackComponent: Fallback, + components: componentOverrides = {}, + widgets, + ...props +}: DynamicWidgetsV2Props) { + const { attributesToRender, facets, refine, toggleShowMore, createURL } = + useDynamicFacets({ widgets, ...props }); + + const explicitWidgets = new Map(); + React.Children.forEach(children, (child) => { + const attr = getWidgetAttribute(child); + if (attr) explicitWidgets.set(attr, child); + }); + + return ( + <> + {attributesToRender.map((attribute) => { + if (explicitWidgets.has(attribute)) + return ( + + {explicitWidgets.get(attribute)} + + ); + + const slice = facets[attribute]; + if (!slice) return null; + + const facetProps: DynamicFacetComponentProps = { + attribute, + slice, + refine: (v) => refine(attribute, v), + toggleShowMore: () => toggleShowMore(attribute), + createURL: (v) => createURL(attribute, v), + }; + + // Resolution chain: descriptor.component > components[type] > built-in > fallback + const Component = + slice.descriptorComponent ?? // 1. per-attribute + componentOverrides[slice.type] ?? // 2. per-type + BUILT_IN_RENDERERS[slice.type] ?? // 3. built-in + Fallback; // 4. escape hatch + + if (!Component) return null; + return ( + + + + ); + })} + + ); +} +``` + +#### Usage examples + +**Zero-config** — just declare types, rendering is automatic: + +```tsx + { + if (attr.startsWith('hierarchicalCategories')) + return { type: 'hierarchicalMenu', attributes: ['...lvl0', '...lvl1'] }; + if (attr === 'brand') return { type: 'menu' }; + return { type: 'refinementList' }; + }} + facets={['*']} +/> +``` + +**Per-type override** — custom menu rendering for all menus: + +```tsx + +``` + +**Per-attribute override** — brand gets a special component: + +```tsx + { + if (attr === 'brand') return { type: 'menu', component: BrandPicker }; + return { type: 'refinementList' }; + }} + facets={['*']} +/> +``` + +**Full custom** — escape hatch for total control: + +```tsx + ( + + )} +/> +``` + +#### Impact on the descriptor type + +The `component` field is added as an optional property on all descriptors: + +```ts +type RefinementListDescriptor = { + type: 'refinementList'; + operator?: 'and' | 'or'; + limit?: number; + showMoreLimit?: number; + component?: ComponentType; // per-attribute override +}; +// Same for MenuDescriptor, HierarchicalMenuDescriptor, etc. +``` + +Note: `component` is a React-level concern. At the connector level (vanilla JS / Vue), the render function receives `slice.descriptorComponent` and can handle it however the framework adapter chooses. + +#### Why this matters + +This eliminates the most common boilerplate in real-world usage. Most users won't need `fallbackComponent` at all — they declare types in `widgets` and get correct rendering automatically. The layered override system provides an escape hatch at every level without adding complexity for the simple case. + +## Code reuse: avoiding reimplementing standalone connectors + +### The problem + +The PoC connector reimplements significant logic from `connectRefinementList`, `connectMenu`, `connectHierarchicalMenu`, and `connectToggleRefinement`: + +| Concern | Standalone connector | PoC reimplements? | +| --- | --- | --- | +| Search parameter registration | `getWidgetSearchParameters` | Yes — addFacet/addDisjunctiveFacet/addHierarchicalFacet | +| Facet value reading + formatting | `getWidgetRenderState` | Yes — getFacetValues, raw fallback, item shape mapping | +| Refinement dispatch | `refine()` closure | Yes — toggleFacetRefinement, add/removeHierarchicalFacetRefinement | +| UI state serialization | `getWidgetUiState` | Yes — scan searchParameters for refinements | +| ShowMore state | Closure-local boolean | Yes — per-attribute Map | +| Insights / sendEvent | `createSendEventForFacet` | No (missing in PoC) | +| searchForFacetValues (SFFV) | `searchForItems()` closure | No (missing in PoC) | + +This duplication means bug fixes in standalone connectors won't propagate, and adding new facet types requires copying their logic. + +### Proposed solution: virtual widget instances + +Instead of reimplementing each facet type's logic, the dynamic facets connector can **instantiate standalone connectors as virtual widgets** — never mounted in the Index, but called as plain objects: + +```ts +// Create a virtual widget (no-op renderFn, never mounted) +const virtualWidget = connectRefinementList(() => {})({ + attribute: 'brand', + operator: 'or', + limit: 10, +}); + +// Reuse its methods directly: +params = virtualWidget.getWidgetSearchParameters!(params, { uiState }); +uiState = virtualWidget.getWidgetUiState!(uiState, { searchParameters }); +const renderState = virtualWidget.getWidgetRenderState!(renderOptions); +``` + +These virtual instances are stored in a persistent `Map` keyed by attribute, so their closure state (showMore toggle, lazy-bound `refine`/`sendEvent`) survives across renders. + +### Reuse feasibility per method + +| Method | Reusable? | Notes | +| --- | --- | --- | +| `getWidgetSearchParameters` | **Yes, fully** | Pure function: `(SearchParameters, { uiState }) → SearchParameters`. No closure dependencies. | +| `getWidgetUiState` | **Yes, fully** | Pure function: `(uiState, { searchParameters }) → IndexUiState`. No closure dependencies. | +| `getWidgetRenderState` | **Yes, with caveats** | Lazily binds to `helper`/`instantSearchInstance` on first call. Needs real `renderOptions` forwarded from the dynamic facets connector. | +| `refine()` | **Yes** | Bound to `helper` on first `getWidgetRenderState` call. Works if `helper` is the real one from the parent Index. | +| `toggleShowMore` | **Partial** | Calls `widget.render!()` internally, which calls the no-op `renderFn`. Dynamic connector must re-read state after toggle. | +| `searchForItems` (SFFV) | **No** | Calls `renderFn()` directly with SFFV results. Needs a wrapper or separate implementation. | +| `sendEvent` | **Yes** | Lazy-initialized from `helper` + `instantSearchInstance`. | + +### How it would work + +```ts +// In the dynamic facets connector closure: +const virtualWidgets = new Map(); + +function getOrCreateVirtual(attribute: string, desc: WidgetDescriptor): Widget { + const key = attribute; + if (virtualWidgets.has(key)) return virtualWidgets.get(key)!; + + let widget: Widget; + switch (desc.type) { + case 'refinementList': + widget = connectRefinementList(() => {})({ + attribute, operator: desc.operator, limit: desc.limit, + showMoreLimit: desc.showMoreLimit, + }); + break; + case 'menu': + widget = connectMenu(() => {})({ + attribute, limit: desc.limit, showMoreLimit: desc.showMoreLimit, + }); + break; + case 'hierarchicalMenu': + widget = connectHierarchicalMenu(() => {})({ + attributes: desc.attributes, separator: desc.separator, + rootPath: desc.rootPath, showParentLevel: desc.showParentLevel, + limit: desc.limit, showMoreLimit: desc.showMoreLimit, + }); + break; + case 'toggleRefinement': + widget = connectToggleRefinement(() => {})({ + attribute, on: desc.on, off: desc.off, + }); + break; + // ... + } + virtualWidgets.set(key, widget!); + return widget!; +} + +// getWidgetSearchParameters: chain through virtual widgets +getWidgetSearchParameters(searchParameters, { uiState }) { + let params = searchParameters; + // Register facets: ['*'] for discovery + for (const facet of facets) params = params.addFacet(facet); + + // Delegate to each virtual widget + for (const attribute of attributesToRegister) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + const vw = getOrCreateVirtual(attribute, desc); + params = vw.getWidgetSearchParameters!(params, { uiState }); + } + return params; +} + +// getWidgetRenderState: collect render state from virtual widgets +getWidgetRenderState(renderOptions) { + const facets: Record = {}; + for (const attribute of attributesToRender) { + const desc = resolveDescriptor(widgetsFn, attribute); + if (!desc) continue; + const vw = getOrCreateVirtual(attribute, desc); + // Forward real renderOptions so lazy bindings work + const rs = vw.getWidgetRenderState!(renderOptions); + // Normalize to FacetSlice + facets[attribute] = { + type: desc.type, attribute, + items: rs.items, refine: rs.refine, + // ... map other fields + }; + } + return { attributesToRender, facets, ... }; +} +``` + +### Tradeoffs + +**Advantages:** + +- Eliminates ~80% of reimplemented code from the PoC +- Bug fixes in standalone connectors (e.g., refinement edge cases, Insights events) propagate automatically +- SFFV and sendEvent come "for free" (except SFFV render pipeline) +- Adding new facet types requires only a new `case` in the factory, not reimplementing their logic + +**Disadvantages:** + +- Creates N virtual widget objects in memory (but: plain objects, no DOM, no React hooks — much cheaper than mounted widgets) +- Each virtual widget has its own `refine()` closure bound to `helper` — the dynamic connector exposes a unified `refine(attribute, value)` that dispatches to the right virtual widget's `refine(value)` +- `FacetSlice` becomes a normalized view over heterogeneous render state types (refinementList returns `{ items, refine, searchForItems }`, menu returns `{ items, refine }`, etc.) +- Virtual widget `toggleShowMore` calls `renderFn` (the no-op), so the dynamic connector must detect state changes and re-render itself +- The two-search bootstrap problem remains: virtual widgets still need `render()` to detect unregistered hierarchical facets + +### Comparison: current PoC vs virtual widgets + +| Aspect | Current PoC (monolithic) | Virtual widgets (composition) | +| --- | --- | --- | +| Lines of code | ~600 (single file) | ~150 (orchestration) + existing connectors | +| Per-facet-type logic | Reimplemented inline | Delegated to standalone connectors | +| SFFV support | Not implemented | Available via virtual widget (needs render wrapper) | +| sendEvent / Insights | Not implemented | Available via virtual widget | +| Maintenance burden | High (N copies of refinement logic) | Low (one factory + normalization) | +| Performance | Slightly better (no object allocation per facet) | Slightly worse (N virtual objects), but negligible | +| Complexity | All in one place (easy to read) | Split across factory + connectors (requires understanding the pattern) | + +### Recommendation + +Phase 1 (current PoC) ships the monolithic implementation to validate the architecture and gather feedback. Phase 2 refactors to virtual widget composition, which is the sustainable path for supporting all facet types without duplication. The `FacetSlice` render state type stays the same — only the internals change. + +## Implementation lessons from PoC + +The working PoC revealed several non-obvious issues: + +### 1. `getFacetValues()` returns `undefined`, not throws + +When a facet is not registered, `getFacetValues()` returns `undefined` — it does NOT throw. Early code had the raw fallback in `catch` blocks that were never reached. + +### 2. `getWidgetSearchParameters` is not re-invoked after render + +Only called during `addWidgets`/`init`. The `render()` method must directly modify `helper.state` and call `scheduleSearch()` to register facets discovered from `facetOrdering`. + +### 3. Duplicate facet registration throws + +The helper throws on duplicate `addHierarchicalFacet()` or `addHierarchicalFacetRefinement()`. Guards (`isHierarchicalFacet()`, `removeHierarchicalFacetRefinement()` before add) are required. + +### 4. `facets: ['*']` does not register hierarchical facets + +`facets: ['*']` makes all attributes available in the raw response as flat facets, but does NOT register them as hierarchical. `getFacetValues()` only works for explicitly registered facets. This is why the raw fallback reads `results._rawResults[0].facets[attribute]`. + +### 5. Menu uses hierarchical facets internally + +Must be registered with `addHierarchicalFacet({ name: attr, attributes: [attr] })`. `getFacetValues()` returns `{ data: [...] }` (tree), not a flat array. + +### 6. Helper item format ≠ public API format + +Helper returns `{ name, escapedValue, path }`. UI components expect `{ label, value }`. Recursive transformation is required. + +## Breaking Changes + +- **`fallbackComponent` signature** is richer (additive — `attribute` prop still present). +- **UI state ownership conflict** if standalone widget + connector manage same attribute. Mitigation: explicit children win. +- **No removal** of existing `connectDynamicWidgets` or ``. + +## Migration Path + +1. **Phase 1 (current PoC):** New connector + hook + component alongside existing ones. +2. **Phase 2:** Add `searchForFacetValues`, `numericMenu`/`range`/`ratingMenu` support. Export UI components publicly. Add Vue/vanilla JS adapters. +3. **Phase 3 (next major):** Deprecate `connectDynamicWidgets`. +4. **Phase 4 (major+1):** Remove old connector. Explicit children become syntactic sugar. + +## Performance + +| Scenario | v1 | v2 | +| ---------- | --------------------------- | --------------------- | +| 10 facets | ~10 widgets, 10 param calls | 1 widget, 1 bulk call | +| 50 facets | ~50 widgets, noticeable lag | 1 widget, interactive | +| 200 facets | severe slowdown / crash | 1 widget, fast | +| 500 facets | not feasible | 1 widget, DOM-bound | + +## Open Questions + +1. **Can we avoid the two-search bootstrap?** If attributes are known upfront (e.g., `knownFacets: string[]`), yes. +2. **`searchForFacetValues` support?** Needs per-attribute search state + debouncing. +3. **Should UI components be publicly exported?** The unified rendering API (§4) requires built-in renderers to wrap `RefinementListUi`/`MenuUi`/`HierarchicalMenuUi`. These are currently internal to `react-instantsearch`. Shipping `DynamicWidgetsV2` with built-in renderers means either (a) making the Ui components public exports, or (b) bundling thin wrappers that re-implement the same markup. Option (a) is cleaner and helps the ecosystem. + +## PoC file inventory + +### Monolithic PoC (reimplements facet logic inline) + +| File | Purpose | +| --- | --- | +| `packages/instantsearch.js/src/connectors/dynamic-facets/connectDynamicFacets.ts` | Core connector (~988 lines) | +| `packages/react-instantsearch-core/src/connectors/useDynamicFacets.ts` | React hook wrapping connector via `useConnector` | +| `packages/react-instantsearch-core/src/components/DynamicWidgetsV2.tsx` | React component (renders `fallbackComponent` per attribute) | + +### Composed PoC (delegates to virtual standalone connector instances) + +| File | Purpose | +| --- | --- | +| `packages/instantsearch.js/src/connectors/dynamic-facets-composed/connectDynamicFacetsComposed.ts` | Composed connector (~589 lines) — creates virtual widgets for each facet | +| `packages/react-instantsearch-core/src/connectors/useDynamicFacetsComposed.ts` | React hook wrapping composed connector | +| `packages/react-instantsearch-core/src/components/DynamicWidgetsV2Composed.tsx` | React component (same pattern as monolithic) | + +### Barrel exports (modified existing files) + +| File | Change | +| --- | --- | +| `packages/instantsearch.js/src/connectors/index.ts` | Added exports for both connectors | +| `packages/react-instantsearch-core/src/index.ts` | Added exports for hooks, components, and prop types | + +### Example app + +| File | Purpose | +| --- | --- | +| `examples/react/dynamic-widgets-v2/` | Working Vite example with 3-mode comparison (V2 monolithic, V2 composed, V1) | +| `examples/react/dynamic-widgets-v2/seed-index.mjs` | Script to create an Algolia index with 1000+ facet attributes | +| `examples/react/dynamic-widgets-v2/src/App.tsx` | Demo app using seeded index `dynamic_facets_v2_poc` | + +## References + +- `packages/instantsearch.js/src/connectors/dynamic-widgets/connectDynamicWidgets.ts` +- `packages/instantsearch.js/src/types/widget.ts` +- `packages/instantsearch.js/src/widgets/index/index.ts` +- `packages/react-instantsearch-core/src/hooks/useConnector.ts` +- `packages/algoliasearch-helper/src/SearchResults/index.js` — `getFacetValues` +- `packages/algoliasearch-helper/src/SearchResults/generate-hierarchical-tree.js` +- `connectHierarchicalMenu._prepareFacetValues` — reference for tree transformation