Skip to content

Commit cab5256

Browse files
e-krebsclaudeHaroenvdrodriguln
committed
feat(instantsearch): SSR support for composition multifeed (#6975)
* feat(helper): support multifeed composition responses Compositions can return multiple result sets (feeds), each identified by a `feedID`. Previously, `_runComposition` spliced only 1 result per derived helper, discarding additional feeds. This changes `queriesCount` to `Infinity` so all feeds are captured, and builds a `_feedResults` map and `_feedOrder` array on the primary `SearchResults` for downstream consumption by `connectFeeds` (Layer 2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump bundlesize limits for multifeed helper changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(helper): omit queriesCount for composition instead of using Infinity Use undefined queriesCount to signal "take all results" in _dispatchAlgoliaResponse, avoiding the Infinity hack. When queriesCount is undefined (composition path), use results directly; when defined (regular search path), splice as before. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(helper): remove composition type definitions, defer to layer 2 The feedID, _feedResults, and _feedOrder type definitions have no consumers yet. Defer the typing decision (interface vs class, naming) to Layer 2 when connectFeeds introduces the first consumer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(helper): use lastResults.feeds array instead of _feedResults map Replace _feedResults (Record<string, SearchResults>) and _feedOrder (string[]) with a single lastResults.feeds (CompositionSearchResults[]) ordered array. This simplifies the API and ensures feeds survive the SSR getInitialResults → JSON → hydration round-trip naturally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(helper): avoid circular ref in multifeed lastResults Create a separate SearchResults instance for lastResults instead of reusing feeds[0], which caused circular references during JSON.stringify (lastResults.feeds[0] === lastResults). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * apply suggestions from code review Co-authored-by: Haroen Viaene <hello@haroen.me> * refactor(instantsearch): extract storeRenderState to shared util Move storeRenderState from module-private in index.ts to render-args.ts so FeedContainer can reuse it in the feeds connector (Layer 2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(instantsearch): centralize index widget types, widen for feedContainer Introduce indexWidgetTypes const and IndexWidgetType as single source of truth for index-like widget types. Update isIndexWidget, IndexWidgetDescription, BuiltinTypes, and BuiltinWidgetTypes to use them. Add 'ais.feeds' and 'ais.feedContainer' to builtin type unions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(metadata): use isIndexWidget to recurse into feed containers The metadata middleware hardcoded 'ais.index' instead of using isIndexWidget, so widgets inside feed containers were invisible to the Algolia Crawler metadata. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(instantsearch): add FeedContainer for per-feed widget subtrees Made-with: Cursor * feat(instantsearch): add connectFeeds connector for multifeed compositions Made-with: Cursor * test(instantsearch): add unit tests for FeedContainer and connectFeeds Made-with: Cursor * chore: bump bundlesize limits for multifeed connector Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(instantsearch): prevent double-init in FeedContainer with initialized flag Replace `instantSearchInstance.started` check with a local `initialized` flag so that child widgets added before `container.init()` are not eagerly initialized. This prevents double-init when the parent index tree calls `init()` on the container after widgets have already been added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(instantsearch): simplify connectFeeds to stateless feedIDs computation Remove FeedContainer lifecycle management from the connector — widget layers now own container creation, registration, and cleanup. The connector only computes feedIDs from results.feeds and exposes them as render state. - Remove `widgets` param and internal `feedContainers` map - Change render state from `feeds: [{ feedID, container }]` to `feedIDs: string[]` - Make `getWidgetRenderState` stateless (derives feedIDs from results) - Simplify `dispose()` to only call `unmountFn()` - Extract shared test helpers to `test/createFeedsTestHelpers.ts` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(instantsearch): fix lint errors in feeds test helpers - Use `import type` for type-only imports - Fix import ordering in test files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * review: use setState * feedback: chain state when removing widgets (same as in dispose below) * feedback: register FeedsWidgetDescription in IndexRenderState Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(instantsearch): add feeds widget for vanilla JS Add `feeds()` widget that creates per-feed DOM containers and manages FeedContainer lifecycle. Each feed gets a scoped `<div class="ais-Feeds-feed">` and its own FeedContainer registered with the parent index. - DOM creation, reuse, reorder, and cleanup per feed - Deferred removal with coalesced timer (matches connectDynamicWidgets pattern) - Dispose merges active + pending containers for clean teardown - Exported from both main and UMD entrypoints - 11 unit tests + 1 integration test with real search lifecycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(react-instantsearch): add Feeds component and useFeeds hook Add `<Feeds>` component that creates per-feed `IndexContext.Provider` scopes, and `useFeeds` hook wrapping `connectFeeds` via `useConnector`. - FeedContainer creation + registration fused in render body for SSR compat - Deferred removal with coalesced timer (single ref pattern from useWidget) - Unmount cleanup merges active + pending containers - 5 unit tests + 2 integration tests (real InstantSearch + SSR) - Exported from react-instantsearch-core Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(vue-instantsearch): add AisFeeds component Add `<ais-feeds>` component with per-feed `AisFeedProvider` scoping via Vue's provide/inject for `$_ais_getParentIndex`. - Container reconciliation in watcher (side-effect-free render) - Deferred removal with coalesced timer (single timer pattern) - Unmount cleanup merges active + pending containers - 5 unit tests + 1 integration test with real InstantSearch - Exported as AisFeeds from vue-instantsearch - Updated mock and index tests for compositionID support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix import ordering lint errors across Layer 3 files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(instantsearch): pass renderOptions to feedContainer.render() for type safety FeedContainer.render() ignores the argument internally but IndexWidget type requires it. Pass renderOptionsRef to satisfy TypeScript. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * temp: add examples * fix: exclude composition examples from v4 type-check @algolia/composition doesn't exist under algoliasearch v4. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(react-instantsearch-core): rename Feeds children render-prop to renderFeed Switch from a children render function to a dedicated `renderFeed` prop with an object argument `({ feedID })`. Keeps the API consistent with other React InstantSearch components (`hitComponent`, `itemComponent`, `bannerComponent`), which all use explicit, non-children customization props. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(vue-instantsearch): use indexOf instead of findIndex in mock removeWidgets findIndex expects a predicate function, not the widget object — the call threw at runtime the first time removeWidgets was invoked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(instantsearch): extract isTwoPassWidget utility Replace inline `$$type === 'ais.dynamicWidgets'` checks with a shared `isTwoPassWidget` predicate. This prepares for the feeds widget which also requires a two-pass SSR cycle, and fixes a missing `shouldRefetch ||` in InitializePromise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(instantsearch): add SSR support for composition multifeed Serialize per-feed results as `compositionFeedsResults` in `getInitialResults`, hydrate them in `hydrateSearchClient`, and reconstruct `lastResults.feeds` in the feeds connector so that composition multifeed works with server-side rendering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(instantsearch): add composition multifeed SSR integration test End-to-end integration test covering the full SSR cycle: server-side rendering with getInitialResults, hydration via hydrateSearchClient, and client-side rehydration of per-feed results. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add Next.js App Router composition example Demo app showing composition multifeed with SSR using react-instantsearch-nextjs. Excluded from v4 type-check since it uses composition-only APIs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(react-instantsearch-core): adapt Feeds usages to renderFeed prop Update the SSR test and the Next.js App Router composition example to the new `renderFeed` prop introduced on Layer 3. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump bundlesize limit for SSR multifeed The composition multifeed SSR additions push the development bundle just over the 256.5 kB ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Haroen Viaene <hello@haroen.me> Co-authored-by: Dan Rodriguez <drodriguln@icloud.com>
1 parent 9340185 commit cab5256

32 files changed

Lines changed: 952 additions & 30 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# next.js
12+
/.next/
13+
/out/
14+
15+
# production
16+
/build
17+
18+
# misc
19+
.DS_Store
20+
*.pem
21+
22+
# debug
23+
npm-debug.log*
24+
yarn-debug.log*
25+
yarn-error.log*
26+
27+
# local env files
28+
.env.local
29+
.env.development.local
30+
.env.test.local
31+
.env.production.local
32+
33+
# vercel
34+
.vercel
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"ignorePatterns": [
3+
".next",
4+
"node_modules",
5+
"next-env.d.ts"
6+
]
7+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import {
5+
Configure,
6+
Feeds,
7+
Highlight,
8+
Hits,
9+
Pagination,
10+
SearchBox,
11+
useFeeds,
12+
} from 'react-instantsearch';
13+
import { InstantSearchNext } from 'react-instantsearch-nextjs';
14+
15+
import { Panel } from '../components/Panel';
16+
import { client } from '../lib/client';
17+
18+
import type { Hit as AlgoliaHit } from 'instantsearch.js';
19+
20+
const compositionID = 'comp1774447423386___products';
21+
22+
export default function Search() {
23+
return (
24+
<InstantSearchNext
25+
// @ts-expect-error compositionClient return type doesn't fully match SearchClient yet
26+
searchClient={client}
27+
compositionID={compositionID}
28+
routing
29+
insights={true}
30+
>
31+
<Configure hitsPerPage={20} />
32+
33+
<div className="Container">
34+
<div>
35+
<Panel header="Search">
36+
<SearchBox placeholder="Product, brand, color, …" />
37+
</Panel>
38+
</div>
39+
40+
<div>
41+
<TabbedFeeds />
42+
43+
<Pagination padding={2} showFirst={false} showLast={false} />
44+
</div>
45+
</div>
46+
</InstantSearchNext>
47+
);
48+
}
49+
50+
function TabbedFeeds() {
51+
const { feedIDs } = useFeeds({ searchScope: 'global' });
52+
const [activeTab, setActiveTab] = useState<string | null>(null);
53+
const current = activeTab ?? feedIDs[0] ?? null;
54+
55+
return (
56+
<>
57+
{feedIDs.length > 0 && (
58+
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
59+
{feedIDs.map((id) => (
60+
<button
61+
key={id}
62+
style={{
63+
padding: '8px 16px',
64+
border: '1px solid #ccc',
65+
borderRadius: '4px',
66+
background: id === current ? '#5468ff' : '#fff',
67+
color: id === current ? '#fff' : '#333',
68+
cursor: 'pointer',
69+
fontWeight: id === current ? 'bold' : 'normal',
70+
}}
71+
onClick={() => setActiveTab(id)}
72+
>
73+
{id || 'All results'}
74+
</button>
75+
))}
76+
</div>
77+
)}
78+
79+
<Feeds
80+
searchScope="global"
81+
renderFeed={({ feedID }) => (
82+
<div style={{ display: feedID === current ? undefined : 'none' }}>
83+
<FeedHits feedID={feedID} />
84+
</div>
85+
)}
86+
/>
87+
</>
88+
);
89+
}
90+
91+
function FeedHits({ feedID }: { feedID: string }) {
92+
switch (feedID) {
93+
case 'products':
94+
return <Hits hitComponent={ProductHit} />;
95+
case 'Fashion':
96+
return <Hits hitComponent={FashionHit} />;
97+
case 'Amazon':
98+
return <Hits hitComponent={AmazonHit} />;
99+
default:
100+
return <Hits hitComponent={ProductHit} />;
101+
}
102+
}
103+
104+
type ProductHitType = AlgoliaHit<{
105+
title: string;
106+
author: string[];
107+
largeImage: string;
108+
}>;
109+
110+
function ProductHit({ hit }: { hit: ProductHitType }) {
111+
return (
112+
<article className="hit">
113+
<header className="hit-image-container">
114+
<img src={hit.largeImage} alt={hit.title} className="hit-image" />
115+
</header>
116+
<div className="hit-info-container">
117+
<p className="hit-category">{hit.author?.join(', ')}</p>
118+
<h1>
119+
<Highlight attribute="title" highlightedTagName="mark" hit={hit} />
120+
</h1>
121+
</div>
122+
</article>
123+
);
124+
}
125+
126+
type FashionHitType = AlgoliaHit<{
127+
name: string;
128+
image: string;
129+
brand: string;
130+
price: number;
131+
currency: string;
132+
}>;
133+
134+
function FashionHit({ hit }: { hit: FashionHitType }) {
135+
return (
136+
<article className="hit">
137+
<header className="hit-image-container">
138+
<img src={hit.image} alt={hit.name} className="hit-image" />
139+
</header>
140+
<div className="hit-info-container">
141+
<p className="hit-category">{hit.brand}</p>
142+
<h1>
143+
<Highlight attribute="name" highlightedTagName="mark" hit={hit} />
144+
</h1>
145+
<p className="hit-description">
146+
{hit.price} {hit.currency}
147+
</p>
148+
</div>
149+
</article>
150+
);
151+
}
152+
153+
type AmazonHitType = AlgoliaHit<{
154+
product_title: string;
155+
product_brand: string;
156+
}>;
157+
158+
function AmazonHit({ hit }: { hit: AmazonHitType }) {
159+
return (
160+
<article className="hit">
161+
<div className="hit-info-container">
162+
<p className="hit-category">{hit.product_brand}</p>
163+
<h1>
164+
<Highlight
165+
attribute="product_title"
166+
highlightedTagName="mark"
167+
hit={hit}
168+
/>
169+
</h1>
170+
</div>
171+
</article>
172+
);
173+
}
25.3 KB
Binary file not shown.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
html,
2+
body {
3+
padding: 0;
4+
margin: 0;
5+
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6+
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7+
}
8+
9+
body {
10+
padding: 0.5rem;
11+
}
12+
13+
* {
14+
box-sizing: border-box;
15+
}
16+
17+
.Container {
18+
display: grid;
19+
align-items: flex-start;
20+
grid-template-columns: minmax(min-content, 200px) 1fr;
21+
gap: 0.5rem;
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import './globals.css';
2+
import 'instantsearch.css/themes/satellite-min.css';
3+
import React from 'react';
4+
5+
export const metadata = {
6+
title: 'Next.js',
7+
description: 'Generated by Next.js',
8+
};
9+
10+
export default function RootLayout({
11+
children,
12+
}: {
13+
children: React.ReactNode;
14+
}) {
15+
return (
16+
<html lang="en">
17+
<body>{children}</body>
18+
</html>
19+
);
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
3+
import { responsesCache } from '../lib/client';
4+
5+
import Search from './Search';
6+
7+
export const dynamic = 'force-dynamic';
8+
9+
export default function Page() {
10+
responsesCache.clear();
11+
12+
return <Search />;
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
export function Panel({
4+
children,
5+
header,
6+
footer,
7+
}: {
8+
children: React.ReactNode;
9+
header?: React.ReactNode;
10+
footer?: React.ReactNode;
11+
}) {
12+
return (
13+
<div className="ais-Panel">
14+
{header && <div className="ais-Panel-header">{header}</div>}
15+
<div className="ais-Panel-body">{children}</div>
16+
{footer && <div className="ais-Panel-footer">{footer}</div>}
17+
</div>
18+
);
19+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const nextPlugin = require('eslint-config-next');
2+
3+
module.exports = [
4+
...nextPlugin,
5+
{
6+
rules: {
7+
// This rule is not able to find the `pages/` folder in the monorepo.
8+
'@next/next/no-html-link-for-pages': 'off',
9+
'react/react-in-jsx-scope': 'off',
10+
'spaced-comment': ['error', 'always', { markers: ['/', '/'] }],
11+
// Allow accessing refs during render when using React's use() hook for suspending
12+
'react-hooks/refs': 'off',
13+
},
14+
},
15+
];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createMemoryCache } from '@algolia/client-common';
2+
import { compositionClient } from '@algolia/composition';
3+
4+
export const responsesCache = createMemoryCache();
5+
export const client = compositionClient(
6+
'9HILZG6EJK',
7+
'65b3e0bb064c4172c4810fb2459bebd1',
8+
{ responsesCache }
9+
);

0 commit comments

Comments
 (0)