diff --git a/.circleci/config.yml b/.circleci/config.yml index aeabb4948f..6946443152 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ references: defaults: &defaults working_directory: ~/instantsearch docker: - - image: cimg/node:14.18.0 + - image: cimg/node:16.14.0 workflows: version: 2 @@ -163,6 +163,7 @@ jobs: - packages/react-instantsearch/dist - packages/react-instantsearch-core/dist - packages/react-instantsearch-router-nextjs/dist + - packages/react-instantsearch-nextjs/dist - packages/vue-instantsearch/vue2 - packages/vue-instantsearch/vue3 diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index b6190875f7..d7c063aae8 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -3,6 +3,7 @@ "instantsearchjs-es-template-pcw1k", "github/algolia/instantsearch/tree/templates/react-instantsearch", "/examples/react/default-theme", + "/examples/react/next-app-router", "/examples/vue/default-theme" ], "buildCommand": "build --no-private --ignore *-maps --ignore *-native", @@ -10,8 +11,9 @@ "packages/instantsearch.js", "packages/react-instantsearch", "packages/react-instantsearch-core", + "packages/react-instantsearch-nextjs", "packages/vue-instantsearch", "packages/instantsearch.css" ], - "node": "14" + "node": "16" } diff --git a/.nvmrc b/.nvmrc index c2324e8e46..832d385064 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14.18.0 +16.14.0 diff --git a/babel.config.js b/babel.config.js index 114bb8d518..facc811fe7 100644 --- a/babel.config.js +++ b/babel.config.js @@ -51,6 +51,8 @@ module.exports = (api) => { 'react-dom', // `use-sync-external-store` also fails if the paths are incomplete 'use-sync-external-store', + // `next` imports as peer dependencies fail if paths are incomplete + 'next', ], }, ], diff --git a/bundlesize.config.json b/bundlesize.config.json index 1013168aeb..9fdfad3fcb 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "56 kB" + "maxSize": "56.25 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", diff --git a/examples/react/default-theme/package.json b/examples/react/default-theme/package.json index ee909b0584..da749d603b 100644 --- a/examples/react/default-theme/package.json +++ b/examples/react/default-theme/package.json @@ -9,8 +9,8 @@ "dependencies": { "algoliasearch": "4.14.3", "instantsearch.js": "4.56.11", - "react": "18.1.0", - "react-dom": "18.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3" }, "devDependencies": { diff --git a/examples/react/e-commerce/package.json b/examples/react/e-commerce/package.json index 78d11ecbb7..fbec8051b2 100644 --- a/examples/react/e-commerce/package.json +++ b/examples/react/e-commerce/package.json @@ -11,9 +11,9 @@ "dependencies": { "algoliasearch": "4.14.3", "instantsearch.js": "4.56.11", - "react": "18.1.0", + "react": "18.2.0", "react-compound-slider": "3.4.0", - "react-dom": "18.1.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3" }, "devDependencies": { diff --git a/examples/react/getting-started/package.json b/examples/react/getting-started/package.json index 4b0f449d70..507ba9964a 100644 --- a/examples/react/getting-started/package.json +++ b/examples/react/getting-started/package.json @@ -9,8 +9,8 @@ "dependencies": { "algoliasearch": "4.14.3", "instantsearch.js": "4.56.11", - "react": "18.1.0", - "react-dom": "18.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3" }, "devDependencies": { diff --git a/examples/react/next-app-router/.eslintrc b/examples/react/next-app-router/.eslintrc new file mode 100644 index 0000000000..e6c614fc86 --- /dev/null +++ b/examples/react/next-app-router/.eslintrc @@ -0,0 +1,9 @@ +{ + "extends": ["plugin:@next/next/recommended"], + "rules": { + // This rule is not able to find the `pages/` folder in the monorepo. + "@next/next/no-html-link-for-pages": ["off"], + "react/react-in-jsx-scope": "off", + "spaced-comment": ["error", "always", { "markers": ["/"] }] + } +} diff --git a/examples/react/next-app-router/.gitignore b/examples/react/next-app-router/.gitignore new file mode 100644 index 0000000000..1437c53f70 --- /dev/null +++ b/examples/react/next-app-router/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/react/next-app-router/README.md b/examples/react/next-app-router/README.md new file mode 100644 index 0000000000..f6a99da12c --- /dev/null +++ b/examples/react/next-app-router/README.md @@ -0,0 +1,18 @@ +This example shows how to do server side rendering with next.js and React InstantSearch. There's a live example here: https://codesandbox.io/s/github/algolia/instantsearch.js/tree/master/examples/react/next. + +[![Edit next](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/algolia/instantsearch/tree/master/examples/react/next) + +## Clone the example + +```sh +curl https://codeload.github.com/algolia/instantsearch/tar.gz/master | tar -xz --strip=3 instantsearch-master/examples/react/next +``` + +## Start the example + +```sh +yarn install --no-lockfile +yarn run dev +``` + +Read more about React InstantSearch [in our documentation](https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/). diff --git a/examples/react/next-app-router/app/Search.tsx b/examples/react/next-app-router/app/Search.tsx new file mode 100644 index 0000000000..122275d840 --- /dev/null +++ b/examples/react/next-app-router/app/Search.tsx @@ -0,0 +1,57 @@ +'use client'; + +import algoliasearch from 'algoliasearch/lite'; +import { Hit as AlgoliaHit } from 'instantsearch.js'; +import React from 'react'; +import { + Hits, + Highlight, + SearchBox, + RefinementList, + DynamicWidgets, +} from 'react-instantsearch'; +import { InstantSearchNext } from 'react-instantsearch-nextjs'; + +import { Panel } from '../components/Panel'; + +const client = algoliasearch('latency', '6be0576ff61c053d5f9a3225e2a90f76'); + +type HitProps = { + hit: AlgoliaHit<{ + name: string; + price: number; + }>; +}; + +function Hit({ hit }: HitProps) { + return ( + <> + + ${hit.price} + + ); +} + +export default function Search() { + return ( + +
+
+ +
+
+ + +
+
+
+ ); +} + +function FallbackComponent({ attribute }: { attribute: string }) { + return ( + + + + ); +} diff --git a/examples/react/next-app-router/app/favicon.ico b/examples/react/next-app-router/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/examples/react/next-app-router/app/favicon.ico differ diff --git a/examples/react/next-app-router/app/globals.css b/examples/react/next-app-router/app/globals.css new file mode 100644 index 0000000000..0ab8aeba8c --- /dev/null +++ b/examples/react/next-app-router/app/globals.css @@ -0,0 +1,22 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +body { + padding: 0.5rem; +} + +* { + box-sizing: border-box; +} + +.Container { + display: grid; + align-items: flex-start; + grid-template-columns: minmax(min-content, 200px) 1fr; + gap: 0.5rem; +} diff --git a/examples/react/next-app-router/app/layout.tsx b/examples/react/next-app-router/app/layout.tsx new file mode 100644 index 0000000000..b235b96b60 --- /dev/null +++ b/examples/react/next-app-router/app/layout.tsx @@ -0,0 +1,20 @@ +import './globals.css'; +import 'instantsearch.css/themes/satellite-min.css'; +import React from 'react'; + +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/examples/react/next-app-router/app/page.module.css b/examples/react/next-app-router/app/page.module.css new file mode 100644 index 0000000000..9411a5e6f2 --- /dev/null +++ b/examples/react/next-app-router/app/page.module.css @@ -0,0 +1,229 @@ +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; + min-height: 100vh; +} + +.description { + display: inherit; + justify-content: inherit; + align-items: inherit; + font-size: 0.85rem; + max-width: var(--max-width); + width: 100%; + z-index: 2; + font-family: var(--font-mono); +} + +.description a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.description p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); +} + +.code { + font-weight: 700; + font-family: var(--font-mono); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(25%, auto)); + width: var(--max-width); + max-width: 100%; +} + +.card { + padding: 1rem 1.2rem; + border-radius: var(--border-radius); + background: rgba(var(--card-rgb), 0); + border: 1px solid rgba(var(--card-border-rgb), 0); + transition: background 200ms, border 200ms; +} + +.card span { + display: inline-block; + transition: transform 200ms; +} + +.card h2 { + font-weight: 600; + margin-bottom: 0.7rem; +} + +.card p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 4rem 0; +} + +.center::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; +} + +.center::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; +} + +.center::before, +.center::after { + content: ''; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); +} + +.logo { + position: relative; +} +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + .card:hover { + background: rgba(var(--card-rgb), 0.1); + border: 1px solid rgba(var(--card-border-rgb), 0.15); + } + + .card:hover span { + transform: translateX(4px); + } +} + +@media (prefers-reduced-motion) { + .card:hover span { + transform: none; + } +} + +/* Mobile */ +@media (max-width: 700px) { + .content { + padding: 4rem; + } + + .grid { + grid-template-columns: 1fr; + margin-bottom: 120px; + max-width: 320px; + text-align: center; + } + + .card { + padding: 1rem 2.5rem; + } + + .card h2 { + margin-bottom: 0.5rem; + } + + .center { + padding: 8rem 0 6rem; + } + + .center::before { + transform: none; + height: 300px; + } + + .description { + font-size: 0.8rem; + } + + .description a { + padding: 1rem; + } + + .description p, + .description div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + .description p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient( + to bottom, + rgba(var(--background-start-rgb), 1), + rgba(var(--callout-rgb), 0.5) + ); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + .description div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient( + to bottom, + transparent 0%, + rgb(var(--background-end-rgb)) 40% + ); + z-index: 1; + } +} + +/* Tablet and Smaller Desktop */ +@media (min-width: 701px) and (max-width: 1120px) { + .grid { + grid-template-columns: repeat(2, 50%); + } +} + +@media (prefers-color-scheme: dark) { + .vercelLogo { + filter: invert(1); + } + + .logo { + filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); + } +} + +@keyframes rotate { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} diff --git a/examples/react/next-app-router/app/page.tsx b/examples/react/next-app-router/app/page.tsx new file mode 100644 index 0000000000..3bdc8880e4 --- /dev/null +++ b/examples/react/next-app-router/app/page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import Search from './Search'; + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return ; +} diff --git a/examples/react/next-app-router/components/Panel.tsx b/examples/react/next-app-router/components/Panel.tsx new file mode 100644 index 0000000000..a33975f38b --- /dev/null +++ b/examples/react/next-app-router/components/Panel.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export function Panel({ + children, + header, + footer, +}: { + children: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+ {header &&
{header}
} +
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/examples/react/next-app-router/next-env.d.ts b/examples/react/next-app-router/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/examples/react/next-app-router/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/react/next-app-router/package.json b/examples/react/next-app-router/package.json new file mode 100644 index 0000000000..6d9a97f059 --- /dev/null +++ b/examples/react/next-app-router/package.json @@ -0,0 +1,28 @@ +{ + "name": "example-react-instantsearch-next-app-dir-example", + "version": "8.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "algoliasearch": "4.14.3", + "instantsearch.css": "8.0.0", + "next": "13.4.19", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-instantsearch": "7.0.3", + "react-instantsearch-router-nextjs": "7.0.3", + "react-instantsearch-nextjs": "0.0.1" + }, + "devDependencies": { + "@types/node": "17.0.40", + "@types/react": "18.0.12", + "eslint": "8.4.0", + "eslint-config-next": "12.0.7", + "typescript": "5.1.3" + } +} diff --git a/examples/react/next-app-router/tsconfig.json b/examples/react/next-app-router/tsconfig.json new file mode 100644 index 0000000000..4c773fef2a --- /dev/null +++ b/examples/react/next-app-router/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/examples/react/next-routing/package.json b/examples/react/next-routing/package.json index 63c6412898..ddbfc15715 100644 --- a/examples/react/next-routing/package.json +++ b/examples/react/next-routing/package.json @@ -11,9 +11,9 @@ "dependencies": { "algoliasearch": "4.14.3", "instantsearch.css": "8.0.0", - "next": "12.1.6", - "react": "18.1.0", - "react-dom": "18.1.0", + "next": "13.4.19", + "react": "18.2.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3", "react-instantsearch-router-nextjs": "7.0.3" }, diff --git a/examples/react/next/package.json b/examples/react/next/package.json index 47d9983f91..1ebbc8350b 100644 --- a/examples/react/next/package.json +++ b/examples/react/next/package.json @@ -11,9 +11,9 @@ "dependencies": { "algoliasearch": "4.14.3", "instantsearch.css": "8.0.0", - "next": "12.1.6", - "react": "18.1.0", - "react-dom": "18.1.0", + "next": "13.4.19", + "react": "18.2.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3", "react-instantsearch-router-nextjs": "7.0.3" }, diff --git a/examples/react/next/tsconfig.json b/examples/react/next/tsconfig.json index c4ab2fbfd5..4c773fef2a 100644 --- a/examples/react/next/tsconfig.json +++ b/examples/react/next/tsconfig.json @@ -17,12 +17,18 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] }, "include": [ "next-env.d.ts", "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/examples/react/ssr/package.json b/examples/react/ssr/package.json index 95d15309cc..8da280acbd 100644 --- a/examples/react/ssr/package.json +++ b/examples/react/ssr/package.json @@ -24,8 +24,8 @@ "dependencies": { "algoliasearch": "4.14.3", "express": "4.17.1", - "react": "18.1.0", - "react-dom": "18.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-instantsearch": "7.0.3" } } diff --git a/jest.config.js b/jest.config.js index c0df242a83..054364825b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,7 @@ const config = { '/packages/algoliasearch-helper', '/packages/create-instantsearch-app', '/packages/react-instantsearch-router-nextjs', + '/packages/react-instantsearch-nextjs', '/__utils__/', ], watchPathIgnorePatterns: [ diff --git a/package.json b/package.json index 3ff1a87cb1..45a8dacfc2 100644 --- a/package.json +++ b/package.json @@ -125,9 +125,9 @@ "places.js": "1.17.1", "prettier": "^2.4.1", "prop-types": "15.6.2", - "react": "18.1.0", - "react-dom": "18.1.0", - "react-test-renderer": "18.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-test-renderer": "18.2.0", "rheostat": "2.2.0", "rollup": "1.29.1", "rollup-plugin-babel": "4.3.3", diff --git a/packages/instantsearch.js/src/lib/routers/history.ts b/packages/instantsearch.js/src/lib/routers/history.ts index f0d2819445..5d887ccb08 100644 --- a/packages/instantsearch.js/src/lib/routers/history.ts +++ b/packages/instantsearch.js/src/lib/routers/history.ts @@ -86,7 +86,7 @@ class BrowserHistory implements Router { /** * Indicates whether the history router is disposed or not. */ - private isDisposed: boolean = false; + protected isDisposed: boolean = false; /** * Indicates the window.history.length before the last call to diff --git a/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx index 79b8456f7b..d15e72237f 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx @@ -607,7 +607,7 @@ describe('InstantSearch', () => { }), }, ]); - expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledTimes(4); }); }); @@ -653,7 +653,7 @@ describe('InstantSearch', () => { rerender(); - expect(warn).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith(false, expect.any(String)); }); test('updates the index on index prop change', async () => { @@ -939,7 +939,7 @@ describe('InstantSearch', () => { ); - expect(warn).toHaveBeenLastCalledWith(false, expect.any(String)); + expect(warn).toHaveBeenCalledWith(true, expect.any(String)); }); test('does not warn when using Next.js with routing with _isNextRouter', () => { @@ -965,7 +965,7 @@ describe('InstantSearch', () => { ); - expect(warn).toHaveBeenLastCalledWith(true, expect.any(String)); + expect(warn).not.toHaveBeenCalledWith(false, expect.any(String)); }); test('does not warn when using Next.js without routing', () => { @@ -980,7 +980,7 @@ describe('InstantSearch', () => { ); - expect(warn).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith(false, expect.any(String)); }); test('does not warn when not using Next.js', () => { @@ -998,7 +998,7 @@ describe('InstantSearch', () => { ); - expect(warn).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith(false, expect.any(String)); }); }); }); diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index 67f24e3b87..1f89e8b6c8 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -28,4 +28,8 @@ export * from './connectors/useStats'; export * from './connectors/useToggleRefinement'; export * from './hooks/useConnector'; export * from './hooks/useInstantSearch'; +export * from './lib/wrapPromiseWithState'; +export * from './lib/useInstantSearchContext'; +export * from './lib/useRSCContext'; +export * from './lib/InstantSearchRSCContext'; export * from './server'; diff --git a/packages/react-instantsearch-core/src/lib/InstantSearchRSCContext.ts b/packages/react-instantsearch-core/src/lib/InstantSearchRSCContext.ts new file mode 100644 index 0000000000..851b46bf03 --- /dev/null +++ b/packages/react-instantsearch-core/src/lib/InstantSearchRSCContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +import type { PromiseWithState } from './wrapPromiseWithState'; +import type { MutableRefObject } from 'react'; + +export type InstantSearchRSCContextApi = + MutableRefObject | null> | null; + +export const InstantSearchRSCContext = + createContext(null); diff --git a/packages/react-instantsearch-core/src/lib/use.ts b/packages/react-instantsearch-core/src/lib/use.ts new file mode 100644 index 0000000000..e1d067877f --- /dev/null +++ b/packages/react-instantsearch-core/src/lib/use.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; + +type Use = (promise: Promise) => T; +const useKey = 'use' as keyof typeof React; + +// @TODO: Remove this file and import directly from React when available. +export const use = React[useKey] as Use; diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts index 5df99a675f..514a7317d5 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts @@ -2,11 +2,12 @@ import InstantSearch from 'instantsearch.js/es/lib/InstantSearch'; import { useCallback, useRef, version as ReactVersion } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { useInstantSearchServerContext } from '../lib/useInstantSearchServerContext'; -import { useInstantSearchSSRContext } from '../lib/useInstantSearchSSRContext'; import version from '../version'; import { useForceUpdate } from './useForceUpdate'; +import { useInstantSearchServerContext } from './useInstantSearchServerContext'; +import { useInstantSearchSSRContext } from './useInstantSearchSSRContext'; +import { useRSCContext } from './useRSCContext'; import { warn } from './warn'; import type { @@ -56,9 +57,13 @@ export function useInstantSearchApi( const forceUpdate = useForceUpdate(); const serverContext = useInstantSearchServerContext(); const serverState = useInstantSearchSSRContext(); + const waitingForResultsRef = useRSCContext(); const initialResults = serverState?.initialResults; const prevPropsRef = useRef(props); + const shouldRenderAtOnce = + serverContext || initialResults || waitingForResultsRef; + let searchRef = useRef | null>( null ); @@ -91,7 +96,7 @@ export function useInstantSearchApi( } as typeof search._schedule; search._schedule.queue = []; - if (serverContext || initialResults) { + if (shouldRenderAtOnce) { // InstantSearch.js has a private Initial Results API that lets us inject // results on the search instance. // On the server, we default the initial results to an empty object so that @@ -110,7 +115,7 @@ export function useInstantSearchApi( // On the server, we start the search early to compute the search parameters. // On SSR, we start the search early to directly catch up with the lifecycle // and render. - if (serverContext || initialResults) { + if (shouldRenderAtOnce) { search.start(); } @@ -121,6 +126,7 @@ export function useInstantSearchApi( } warnNextRouter(props.routing); + warnNextAppDir(Boolean(waitingForResultsRef)); searchRef.current = search; } @@ -266,6 +272,22 @@ You can ignore this warning if you are using a custom router that suits your nee } } +function warnNextAppDir(isRscContextDefined: boolean) { + if (!__DEV__ || typeof window === 'undefined' || isRscContextDefined) { + return; + } + + warn( + Boolean((window as any).next?.appDir) === false, + ` +We've detected you are using Next.js with the App Router. +We released an **experimental** package called "react-instantsearch-nextjs" that makes SSR work with the App Router. +Please check its usage instructions: https://www.algolia.com/doc/guides/building-search-ui/going-further/server-side-rendering/react/#with-nextjs + +This warning will not be outputted in production builds.` + ); +} + /** * Gets the version of Next.js if it is available in the `window` object, * otherwise it returns the NEXT_RUNTIME environment variable (in SSR), diff --git a/packages/react-instantsearch-core/src/lib/useRSCContext.ts b/packages/react-instantsearch-core/src/lib/useRSCContext.ts new file mode 100644 index 0000000000..a1b6774f8c --- /dev/null +++ b/packages/react-instantsearch-core/src/lib/useRSCContext.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { InstantSearchRSCContext } from './InstantSearchRSCContext'; + +export function useRSCContext() { + return useContext(InstantSearchRSCContext); +} diff --git a/packages/react-instantsearch-core/src/lib/useWidget.ts b/packages/react-instantsearch-core/src/lib/useWidget.ts index 6463803216..8410460ef1 100644 --- a/packages/react-instantsearch-core/src/lib/useWidget.ts +++ b/packages/react-instantsearch-core/src/lib/useWidget.ts @@ -1,8 +1,10 @@ import { useEffect, useRef } from 'react'; import { dequal } from './dequal'; +import { use } from './use'; import { useInstantSearchContext } from './useInstantSearchContext'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { useRSCContext } from './useRSCContext'; import type { Widget } from 'instantsearch.js'; import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; @@ -18,6 +20,8 @@ export function useWidget({ props: TProps; shouldSsr: boolean; }) { + const waitingForResultsRef = useRSCContext(); + const prevPropsRef = useRef(props); useEffect(() => { prevPropsRef.current = props; @@ -83,7 +87,24 @@ export function useWidget({ }; }, [parentIndex, widget, shouldAddWidgetEarly, search, props]); - if (shouldAddWidgetEarly) { + if ( + shouldAddWidgetEarly || + waitingForResultsRef?.current?.status === 'pending' + ) { parentIndex.addWidgets([widget]); } + + if ( + typeof window === 'undefined' && + waitingForResultsRef?.current && + // We need the widgets contained in the index to be added before we trigger the search request. + widget.$$type !== 'ais.index' + ) { + use(waitingForResultsRef.current); + // If we made a second request because of DynamicWidgets, we need to wait for the second result, + // except for DynamicWidgets itself which needs to render its children after the first result. + if (widget.$$type !== 'ais.dynamicWidgets' && search.helper?.lastResults) { + use(waitingForResultsRef.current); + } + } } diff --git a/packages/react-instantsearch-core/src/lib/wrapPromiseWithState.ts b/packages/react-instantsearch-core/src/lib/wrapPromiseWithState.ts new file mode 100644 index 0000000000..853489eb98 --- /dev/null +++ b/packages/react-instantsearch-core/src/lib/wrapPromiseWithState.ts @@ -0,0 +1,60 @@ +// This is needed in order to work with RSC Suspense, perhaps they will later provide a wrapper. + +interface PendingPromise extends Promise { + status: 'pending'; +} + +interface FulfilledPromise extends Promise { + status: 'fulfilled'; + value: TValue; +} + +interface RejectedPromise extends Promise { + status: 'rejected'; + reason: unknown; +} + +export type PromiseWithState = + | PendingPromise + | FulfilledPromise + | RejectedPromise; + +function isStatefulPromise( + promise: Promise +): promise is PromiseWithState { + return 'status' in promise; +} + +export function wrapPromiseWithState( + promise: Promise +): PromiseWithState { + if (isStatefulPromise(promise)) { + return promise; + } + + const pendingPromise = promise as PendingPromise; + pendingPromise.status = 'pending'; + + pendingPromise.then( + (value) => { + if (pendingPromise.status === 'pending') { + const fulfilledPromise = + pendingPromise as unknown as FulfilledPromise; + + fulfilledPromise.status = 'fulfilled'; + fulfilledPromise.value = value; + } + }, + (reason: unknown) => { + if (pendingPromise.status === 'pending') { + const rejectedPromise = + pendingPromise as unknown as RejectedPromise; + + rejectedPromise.status = 'rejected'; + rejectedPromise.reason = reason; + } + } + ); + + return promise as PromiseWithState; +} diff --git a/packages/react-instantsearch-nextjs/README.md b/packages/react-instantsearch-nextjs/README.md new file mode 100644 index 0000000000..75eee451f1 --- /dev/null +++ b/packages/react-instantsearch-nextjs/README.md @@ -0,0 +1,147 @@ + + + + +- [react-instantsearch-nextjs](#react-instantsearch-nextjs) + - [Installation](#installation) + - [Usage](#usage) + - [API](#api) + - [``](#instantsearchnext) + - [`routing` prop](#routing-prop) + - [Troubleshooting](#troubleshooting) + - [Contributing](#contributing) + - [License](#license) + + + +# react-instantsearch-nextjs + +This package provides server-side rendering for [React InstantSearch](https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/) that is compatible with [Next.js 13 App Router](https://nextjs.org/docs/app). + +> [!WARNING] +> **This package is experimental.** + +## Installation + +```sh +yarn add react-instantsearch-nextjs +# or with npm +npm install react-instantsearch-nextjs +``` + +## Usage + +> [!NOTE] +> You can check this documentation on [Algolia's Documentation website](https://www.algolia.com/doc/guides/building-search-ui/going-further/server-side-rendering/react/#app-router-experimental). + +Your search component must be in its own file, and it shouldn't be named `page.js` or `page.tsx`. + +To render the component in the browser and allow users to interact with it, include the "use client" directive at the top of your code. + +```diff ++'use client'; +import algoliasearch from 'algoliasearch/lite'; +import { + InstantSearch, + SearchBox, +} from 'react-instantsearch'; + +const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'); + +export function Search() { + return ( + + + {/* other widgets */} + + ); +} +``` + +Import the `` component from the `react-instantsearch-nextjs` package, and replace the <%= widget_link('instantsearch', 'react') %> component with it, without changing the props. + + +```diff +'use client'; +import algoliasearch from 'algoliasearch/lite'; +import { +- InstantSearch, + SearchBox, +} from 'react-instantsearch'; ++import { InstantSearchNext } from 'react-instantsearch-nextjs'; + +const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'); + +export function Search() { + return ( +- ++ + + {/* other widgets */} + + ); +} +``` + +To serve your search page at `/search`, create an `app/search` directory. Inside it, create a `page.js` file (or `page.tsx` if you're using TypeScript). + +Make sure to [configure your route segment to be dynamic](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic) so that Next.js generates a new page for each request. + +```jsx +// app/search/page.js or app/search/page.tsx +import { Search } from './Search'; // change this with the path to your component + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return ; +} +``` + +You can now visit `/search` to see your server-side rendered search page. + +## API + +### `` + +The `` component is a drop-in replacement for the `` component. It accepts the same props, and it renders the same UI. + +You can check the [InstantSearch API reference](https://www.algolia.com/doc/api-reference/widgets/instantsearch/react/) for more information. + +### `routing` prop + +As with the `` component, you can pass a `routing` prop to the `` component to customize the routing behavior. The difference here is that `routing.router` takes [the same options as the `historyRouter`](https://www.algolia.com/doc/api-reference/widgets/history-router/react/). + +## Troubleshooting + +If you're experiencing issues, please refer to the [**Need help?**](https://algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/#need-help) section of the docs, or [open a new issue](https://github.com/algolia/instantsearch.js/issues/new?assignees=&labels=triage&template=BUG_REPORT.yml). + +## Contributing + +We welcome all contributors, from casual to regular 💙 + +- **Bug report**. Is something not working as expected? [Send a bug report][contributing-bugreport]. +- **Feature request**. Would you like to add something to the library? [Send a feature request][contributing-featurerequest]. +- **Documentation**. Did you find a typo in the doc? [Open an issue][contributing-newissue] and we'll take care of it. +- **Development**. If you don't know where to start, you can check the open issues that are [tagged easy][contributing-label-easy], the [bugs][contributing-label-bug] or [chores][contributing-label-chore]. + +To start contributing to code, you need to: + +1. [Fork the project](https://help.github.com/articles/fork-a-repo/) +1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) +1. Install the dependencies: `yarn` + +Please read [our contribution process](https://github.com/algolia/instantsearch/blob/master/CONTRIBUTING.md) to learn more. + +## License + +React InstantSearch is [MIT licensed](../../LICENSE). + + + +[contributing-bugreport]: https://github.com/algolia/instantsearch/issues/new?template=BUG_REPORT.yml&labels=triage,Library%3A%20React+InstantSearch +[contributing-featurerequest]: https://github.com/algolia/instantsearch/discussions/new?category=ideas&labels=triage,Library%3A%20React+InstantSearch&title=Feature%20request%3A%20 +[contributing-newissue]: https://github.com/algolia/instantsearch/issues/new?labels=triage,Library%3A%20React+InstantSearch +[contributing-label-easy]: https://github.com/algolia/instantsearch/issues?q=is%3Aopen+is%3Aissue+label%3A%22Difficulty%3A+Easy%22+label%3A%22Library%3A%20React+InstantSearch%22 +[contributing-label-bug]: https://github.com/algolia/instantsearch/issues?q=is%3Aissue+is%3Aopen+label%3A%22Type%3A+Bug%22+label%3A%22Library%3A%20React+InstantSearch%22 +[contributing-label-chore]: https://github.com/algolia/instantsearch/issues?q=is%3Aissue+is%3Aopen+label%3A%22Type%3A+Chore%22+label%3A%22Library%3A%20React+InstantSearch%22 diff --git a/packages/react-instantsearch-nextjs/__tests__/module/is-cjs-module.cjs b/packages/react-instantsearch-nextjs/__tests__/module/is-cjs-module.cjs new file mode 100644 index 0000000000..c29e628bfa --- /dev/null +++ b/packages/react-instantsearch-nextjs/__tests__/module/is-cjs-module.cjs @@ -0,0 +1,10 @@ +/* eslint-disable no-console */ + +const assert = require('assert'); + +require('next'); +const ReactInstantSearchSSRNext = require('react-instantsearch-nextjs'); + +assert.ok(ReactInstantSearchSSRNext); + +console.log('react-instantsearch-nextjs is valid CJS'); diff --git a/packages/react-instantsearch-nextjs/__tests__/module/is-es-module.mjs b/packages/react-instantsearch-nextjs/__tests__/module/is-es-module.mjs new file mode 100644 index 0000000000..3d25abdd76 --- /dev/null +++ b/packages/react-instantsearch-nextjs/__tests__/module/is-es-module.mjs @@ -0,0 +1,9 @@ +/* eslint-disable no-console */ +import assert from 'assert'; + +import 'next'; +import * as ReactInstantSearchSSRNext from 'react-instantsearch-nextjs'; + +assert.ok(ReactInstantSearchSSRNext); + +console.log('react-instantsearch-nextjs is valid ESM'); diff --git a/packages/react-instantsearch-nextjs/package.json b/packages/react-instantsearch-nextjs/package.json new file mode 100644 index 0000000000..4d06f80945 --- /dev/null +++ b/packages/react-instantsearch-nextjs/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-instantsearch-nextjs", + "version": "0.0.1", + "description": "React InstantSearch SSR utilities for Next.js", + "source": "src/index.ts", + "types": "dist/es/index.d.ts", + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "type": "module", + "exports": { + "types": "./dist/es/index.d.ts", + "require": "./dist/cjs/index.js", + "default": "./dist/es/index.js" + }, + "sideEffects": false, + "license": "MIT", + "homepage": "https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/", + "repository": { + "type": "git", + "url": "https://github.com/algolia/instantsearch" + }, + "author": { + "name": "Algolia, Inc.", + "url": "https://www.algolia.com" + }, + "keywords": [ + "algolia", + "ssr", + "app", + "app router", + "fast", + "instantsearch", + "react", + "search", + "next", + "nextjs" + ], + "files": [ + "README.md", + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "yarn build:cjs && yarn build:es && yarn build:types", + "build:cjs": "BABEL_ENV=cjs babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/cjs --ignore '**/__tests__/**/*','**/__mocks__/**/*' --quiet && ../../scripts/prepare-cjs.sh", + "build:es": "BABEL_ENV=es babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/es --ignore '**/__tests__/**/*','**/__mocks__/**/*' --quiet", + "build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/es", + "test:exports": "node ./__tests__/module/is-es-module.mjs && node ./__tests__/module/is-cjs-module.cjs" + }, + "devDependencies": { + "instantsearch.js": "4.56.11", + "next": "13.4.19", + "react-instantsearch-core": "7.0.3" + }, + "peerDependencies": { + "next": ">= 13.4 && < 14", + "react-instantsearch": ">= 7.1.0 && < 8" + } +} diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.tsx b/packages/react-instantsearch-nextjs/src/InitializePromise.tsx new file mode 100644 index 0000000000..df623a4cf5 --- /dev/null +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.tsx @@ -0,0 +1,76 @@ +import { getInitialResults } from 'instantsearch.js/es/lib/server'; +import { walkIndex } from 'instantsearch.js/es/lib/utils'; +import { ServerInsertedHTMLContext } from 'next/navigation'; +import React, { useContext } from 'react'; +import { + useInstantSearchContext, + useRSCContext, + wrapPromiseWithState, +} from 'react-instantsearch-core'; + +export function InitializePromise() { + const search = useInstantSearchContext(); + const waitForResultsRef = useRSCContext(); + const insertHTML = + useContext(ServerInsertedHTMLContext) || + (() => { + throw new Error('Missing ServerInsertedHTMLContext'); + }); + + const waitForResults = () => + new Promise((resolve) => { + search.mainHelper!.derivedHelpers[0].on('result', () => { + resolve(); + }); + }); + + const injectInitialResults = () => { + let inserted = false; + const results = getInitialResults(search.mainIndex); + insertHTML(() => { + if (inserted) { + return <>; + } + inserted = true; + return ( +