algolia / react-instantsearch

⚡️ Lightning-fast search for React and React Native applications, by Algolia.
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/
MIT License
1.97k stars 386 forks source link

React instantSearch hooks SSR - getServerState can not read from webpack bundle #3572

Closed MarcoLeko closed 1 year ago

MarcoLeko commented 2 years ago

🐛 Bug description

I'm trying to implement algolia with SSR capabilities into our platform by doing a POC. There are a few steps that seems need to be done for SSR:

  • On the server, retrieve the initial search results of the current search state (determined by the router, or more precisely the underlying UI state and its associated widgets)
  • Then, on the server, render these initial search results to HTML and send the response
  • Then, on the client, load the JavaScript code for InstantSearch
  • Then, on the client, hydrate InstantSearch

For us it is failing already on the first step, by throwing: Unable to retrieve InstantSearch's server state in getServerState(). Did you mount the <InstantSearch> component?","stack":"Error: Unable to retrieve InstantSearch's server state ingetServerState(). Did you mount the <InstantSearch> component?\n at .../node_modules/react-instantsearch-hooks-server/dist/cjs/getServerState.js:100:13\n

What seems to be the case is that the getServerState can somehow not detect from the webpack bundle that there is a InstantSearch component defined:

Source code

entry.js

Click to expand! ```javascript const name = 'product-listing-fragment'; export function createFragment(RootComponent) { const Fragment = ({ rootComponentProps }) => { return ; }; async function init(rootElement) { const fragmentData = window?.Evelin?.data?.[name]; const { rootComponentProps } = fragmentData; hydrate(, rootElement); } async function renderFn(rootElement, { rootComponentProps = {} }) { if (!rootElement) return; render(, rootElement); } return { Fragment, init, render: renderFn }; } const algoliaClient = algoliasearch('foo', 'bar'); const Page = ({ serverState }) => { return (
Marco
); }; const { Fragment, init } = createFragment(Page); // init function provides hydrate function and Fragment is the actual React Element // Keep in mind init function for hydration is called somewhere else with the rootProps export { Fragment, init }; ```

server.js

Click to expand! ```javascript function getFragment(fragmentAssets) { // dynamically requires from entry point in dist folder const { Fragment } = require(path.join( paths.clientOutputDirectory, MODERN_BROWSER_STAGE || 'modern', getFragmentEntryPointFromAssets(fragmentAssets), )); return Fragment; } const Fragment = getFragment(assetsStaticConfigPerStage.$entrypoints); // path to dist entry point app.get('/', async (req, res) => { const rootComponentProps = await getRootComponentProps({ // here getServerState is called and serverState is part of rootComponentProps Fragment, }); const fragmentData = { ...someOtherData rootComponentProps: rootComponentProps || {}, }; const fragmentHtml = renderToString(createElement(Fragment, fragmentData)); return res .code(200) .send(fragmentHtml); }); } ```

getRootComponentProps.js

Click to expand! ```javascript // a fragment can have initial data which can be fetched asynchronously // the params passed to this function are the query parameters export async function getRootComponentProps({ Fragment }) { let serverState = {}; try { serverState = await getServerState(); } catch (e) { console.log(e); } return { serverState, }; } ```

JS-Bundle WITHOUT WEBPACK (BABEL ONLY) - getServerState is WORKING!

Click to expand! ```javascript "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Fragment = void 0; exports.createFragment = createFragment; var _reactInstantsearchHooksWeb = require("react-instantsearch-hooks-web"); var _lite = _interopRequireDefault(require("algoliasearch/lite")); var _jsxDevRuntime = require("react/jsx-dev-runtime"); var _jsxFileName = "/Users/lekoma/evelin/product-listing-fragment/src/server/entry.js"; function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const name = 'product-listing-fragment'; function createFragment(RootComponent, getFragmentContext = () => ({}), initDependencies = () => null) { const Fragment = ({ rootComponentProps }) => { return /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)(RootComponent, { ...rootComponentProps }, void 0, false, { fileName: _jsxFileName, lineNumber: 12, columnNumber: 16 }, this); }; async function init(rootElement) { const fragmentData = window?.Evelin?.data?.[name]; if (!fragmentData) { throw new Error(`Could not initialize ${name} because there was no fragment data for it inside Evelin.data. Got: ${fragmentData}`); } if (!rootElement) { throw new Error(`Could not initialize ${name} because no valid container element was given. Got: ${rootElement}`); } const { baseUrl, tenant, locale, sentry, rootComponentProps } = fragmentData; await initDependencies({ baseUrl, tenant, locale }); React.hydrate( /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)(Fragment, { baseUrl: baseUrl, tenant: tenant, locale: locale, sentry: sentry, rootComponentProps: rootComponentProps }, void 0, false, { fileName: _jsxFileName, lineNumber: 34, columnNumber: 13 }, this), rootElement); } async function renderFn(rootElement, { baseUrl = '/', tenant, locale, sentry, rootComponentProps = {} }) { if (!rootElement) return; await initDependencies({ baseUrl, tenant, locale }); React.render( /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)(Fragment, { baseUrl: baseUrl, tenant: tenant, locale: locale, rootComponentProps: rootComponentProps, sentry: sentry }, void 0, false, { fileName: _jsxFileName, lineNumber: 54, columnNumber: 13 }, this), rootElement); } return { Fragment, init, render: renderFn }; } const algoliaClient = (0, _lite.default)('CM7VJJA0AR', 'b65c925262ff32ce527aadd50da2bfe6'); const Page = ({ serverState }) => { return /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)(_reactInstantsearchHooksWeb.InstantSearchSSRProvider, { ...serverState, children: /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)(_reactInstantsearchHooksWeb.InstantSearch, { indexName: "loki_js_de_de_product", searchClient: algoliaClient, children: /*#__PURE__*/(0, _jsxDevRuntime.jsxDEV)("h1", { children: "Marco" }, void 0, false, { fileName: _jsxFileName, lineNumber: 74, columnNumber: 17 }, void 0) }, void 0, false, { fileName: _jsxFileName, lineNumber: 73, columnNumber: 13 }, void 0) }, void 0, false, { fileName: _jsxFileName, lineNumber: 72, columnNumber: 9 }, void 0); }; const { Fragment, init } = createFragment(Page); exports.Fragment = Fragment; ```

JS-Bundle WITH WEBPACK & BABEL

Click to expand! ```javascript (function webpackUniversalModuleDefinition(root, factory) { if(typeof exports === 'object' && typeof module === 'object') module.exports = factory(require("react"), require("react-dom")); else if(typeof define === 'function' && define.amd) define(["react", "react-dom"], factory); else if(typeof exports === 'object') exports["Evelin"] = factory(require("react"), require("react-dom")); else root["Evelin"] = root["Evelin"] || {}, root["Evelin"]["fragments"] = root["Evelin"]["fragments"] || {}, root["Evelin"]["fragments"]["product-listing-fragment"] = factory(root["Evelin"]["fragments"]["react-base-fragment"]["React"], root["Evelin"]["fragments"]["react-base-fragment"]["ReactDom"]); })((typeof self != 'undefined' ? self : this), (__WEBPACK_EXTERNAL_MODULE_react__, __WEBPACK_EXTERNAL_MODULE_react_dom__) => { return /******/ (() => { // webpackBootstrap /******/ var __webpack_modules__ = ({ /***/ "./node_modules/@jsmdg/react-fragment-scripts/fragment/Fragment.jsx": /*!**************************************************************************!*\ !*** ./node_modules/@jsmdg/react-fragment-scripts/fragment/Fragment.jsx ***! \**************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export */ __webpack_require__.d(__webpack_exports__, { /* harmony export */ "createFragment": () => (/* binding */ createFragment) /* harmony export */ }); /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react-dom */ "react-dom"); /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js"); var _jsxFileName = "/Users/lekoma/evelin/product-listing-fragment/node_modules/@jsmdg/react-fragment-scripts/fragment/Fragment.jsx"; /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable import/no-dynamic-require */ const { name } = __webpack_require__(/*! ./package.json */ "./package.json"); function createFragment(RootComponent) { // eslint-disable-next-line react/prop-types const Fragment = ({ rootComponentProps }) => { return /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(RootComponent, { ...rootComponentProps }, void 0, false, { fileName: _jsxFileName, lineNumber: 10, columnNumber: 16 }, this); }; async function init(rootElement) { var _window, _window$Evelin, _window$Evelin$data; const fragmentData = (_window = window) === null || _window === void 0 ? void 0 : (_window$Evelin = _window.Evelin) === null || _window$Evelin === void 0 ? void 0 : (_window$Evelin$data = _window$Evelin.data) === null || _window$Evelin$data === void 0 ? void 0 : _window$Evelin$data[name]; if (!fragmentData) { throw new Error(`Could not initialize ${name} because there was no fragment data for it inside Evelin.data. Got: ${fragmentData}`); } if (!rootElement) { throw new Error(`Could not initialize ${name} because no valid container element was given. Got: ${rootElement}`); } const { baseUrl, tenant, locale, sentry, rootComponentProps } = fragmentData; (0, react_dom__WEBPACK_IMPORTED_MODULE_0__.hydrate)( /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(Fragment, { baseUrl: baseUrl, tenant: tenant, locale: locale, sentry: sentry, rootComponentProps: rootComponentProps }, void 0, false, { fileName: _jsxFileName, lineNumber: 30, columnNumber: 13 }, this), rootElement); } async function renderFn(rootElement, { baseUrl = '/', tenant, locale, sentry, rootComponentProps = {} }) { if (!rootElement) return; (0, react_dom__WEBPACK_IMPORTED_MODULE_0__.render)( /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(Fragment, { baseUrl: baseUrl, tenant: tenant, locale: locale, rootComponentProps: rootComponentProps, sentry: sentry }, void 0, false, { fileName: _jsxFileName, lineNumber: 48, columnNumber: 13 }, this), rootElement); } return { Fragment, init, render: renderFn }; } /***/ }), /***/ "./node_modules/@jsmdg/react-fragment-scripts/fragment/index.js": /*!**********************************************************************!*\ !*** ./node_modules/@jsmdg/react-fragment-scripts/fragment/index.js ***! \**********************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export */ __webpack_require__.d(__webpack_exports__, { /* harmony export */ "createFragment": () => (/* reexport safe */ _Fragment__WEBPACK_IMPORTED_MODULE_0__.createFragment) /* harmony export */ }); /* harmony import */ var _Fragment__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./Fragment */ "./node_modules/@jsmdg/react-fragment-scripts/fragment/Fragment.jsx"); ... /************************************************************************/ var __webpack_exports__ = {}; // This entry need to be wrapped in an IIFE because it need to be in strict mode. (() => { "use strict"; /*!*****************************!*\ !*** ./src/client/index.js ***! \*****************************/ __webpack_require__.r(__webpack_exports__); /* harmony export */ __webpack_require__.d(__webpack_exports__, { /* harmony export */ "Fragment": () => (/* binding */ Fragment), /* harmony export */ "init": () => (/* binding */ init) /* harmony export */ }); /* harmony import */ var _jsmdg_react_fragment_scripts_fragment__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @jsmdg/react-fragment-scripts/fragment */ "./node_modules/@jsmdg/react-fragment-scripts/fragment/index.js"); /* harmony import */ var algoliasearch_lite__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! algoliasearch/lite */ "./node_modules/algoliasearch/dist/algoliasearch-lite.umd.js"); /* harmony import */ var algoliasearch_lite__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(algoliasearch_lite__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var react_instantsearch_hooks_web__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! react-instantsearch-hooks-web */ "./node_modules/react-instantsearch-hooks/dist/es/components/InstantSearchSSRProvider.js"); /* harmony import */ var react_instantsearch_hooks_web__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! react-instantsearch-hooks-web */ "./node_modules/react-instantsearch-hooks/dist/es/components/InstantSearch.js"); /* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js"); var _jsxFileName = "/Users/lekoma/evelin/product-listing-fragment/src/client/index.js"; /* eslint-disable react/jsx-props-no-spreading, react/destructuring-assignment, react/prop-types, react-intl-auto/no-jsx-string-literals, import/no-default-export */ const algoliaClient = algoliasearch_lite__WEBPACK_IMPORTED_MODULE_2___default()('CM7VJJA0AR', 'b65c925262ff32ce527aadd50da2bfe6'); const Page = ({ serverState }) => { return /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_instantsearch_hooks_web__WEBPACK_IMPORTED_MODULE_3__.InstantSearchSSRProvider, { ...serverState, children: /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_instantsearch_hooks_web__WEBPACK_IMPORTED_MODULE_4__.InstantSearch, { indexName: "loki_js_de_de_product", searchClient: algoliaClient, children: /*#__PURE__*/(0, react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)("div", { children: "Marco" }, void 0, false, { fileName: _jsxFileName, lineNumber: 12, columnNumber: 17 }, undefined) }, void 0, false, { fileName: _jsxFileName, lineNumber: 11, columnNumber: 13 }, undefined) }, void 0, false, { fileName: _jsxFileName, lineNumber: 10, columnNumber: 9 }, undefined); }; const { Fragment, init } = (0, _jsmdg_react_fragment_scripts_fragment__WEBPACK_IMPORTED_MODULE_0__.createFragment)(Page); })(); /******/ return __webpack_exports__; /******/ })() }); }); }) ```

[DISCLAIMER] The Webpack/Bable bundle is actually shortened because it contained all the code from algolia, which was absolete for this showcase.

💭 Expected behavior

getServerState should be able to detect the InstantSearchcomponent and its initial uiState and perform the actual search

Environment

"react-instantsearch-hooks-server": "^6.30.2", "react-instantsearch-hooks-web": "^6.30.2",

Sorry in advance for the long snippets, webpack things 🤷🏻‍♂️

Haroenv commented 2 years ago

Thanks for this code already, do you also have a repository or somewhere simple where this reproduces?

MarcoLeko commented 2 years ago

Unfurtunately our project runs on a custom micro-frontend architecture, splittet across multiple private coporate repositories :/

Im sorry for this, usually having something reproducable eases up way more the bug finding process, but maybe you guys can tell from the first glance why getServerState is unable to retrieve the InstantSearch component from the webpack bundle

francoischalifour commented 2 years ago

getServerState() relies on a React Context to read from <InstantSearch>. It's possible that you have duplicated versions of react-instantsearch-hooks in your bundle. And therefore that the React Context reference changes, so getServerState() is unable to read from the Context.

Haroenv commented 2 years ago

With just the code you sent it would be really hard, as I can't see how you're calling getServerState, is it equivalent to

https://github.com/algolia/react-instantsearch/blob/6d0884437ed6ae061a8e1a4a983517f764efb6a2/examples/hooks-ssr/src/server.js#L16

but with Page instead of App?

I understand it's not easy to reproduce, but likely this is something to do with how the code is instantiated and would also happen without MFE architecture.

As a starter, this is our main server side rendering example in codesandbox, that you can edit to be closer to your version where it throws that error. It's also using webpack: https://codesandbox.io/s/heuristic-davinci-l7ckp8

MarcoLeko commented 2 years ago

Hey guys so I extended the bug report with the server part and more details to the entry component part of the code-base. The main differences that I can see from the codesandbox and our code base are mainly two points:

MarcoLeko commented 2 years ago

I tried to make a code-sandbox wie a similar process to what we currently have, by dynamically requiring the client entry bundle in the server and pass it to getServerState. Unfurtunately webpack is not able to read from bundle.js, but you got hopefully the idea: https://codesandbox.io/s/pedantic-frost-0xgvk2?file=/src/server.js

dhayab commented 2 years ago

Hi, not sure if it can fit your real architecture, but if you can import the unbundled App component in server.js it won't cause issues. https://codesandbox.io/s/zen-margulis-eu4vpe?file=/src/server.js:453-500

MarcoLeko commented 2 years ago

hmm it is really unfurtunate that it only works like this :/

The server part of our micro-frontend (called backend for frontend) is strictly decoupled from the client of the micro-frontend. Meaning importing something from the client is a violation. I think it would even mean, that as soon as we would import something from the client in the server part, we would create a client bundle in the server bundle of the application (bundle redundancy).

Do you have a guess why the getServerState is not able to retrieve the InstantSearch component from a bundled javascript asset and only able to get it from the unbundled source entry file?

francoischalifour commented 2 years ago

@MarcoLeko As mentioned in https://github.com/algolia/react-instantsearch/issues/3572#issuecomment-1191427446, this is because the Context variable reference changes from your client bundle to your server bundle.

You can find more informations on this problem on this StackOverflow thread.

MarcoLeko commented 2 years ago

Referring to your comment: Im wondering by checking our node_modules there is only one version of react-instantsearch-hooks in the node_modules (Usually packages might have their own node_modules, with specific versions of a package, but this is for us not the case). Just as a question are there some other ways to retrieve the serverState in the server and pass it down to the client?

Example

Server part

export async function getRootComponentProps({ Fragment }) {

    try {
        serverState = await index.search('') // should replace getServerState; initial search needs to be in sync with the initialUiState prop of InstantSearch component
    } catch (e) {
        console.log(e);
    }

    return {
        serverState,
    };
}

Client part

const Page = ({ serverState }) => {
    return (
        <InstantSearchSSRProvider {...serverState}>
            <InstantSearch searchClient={algoliaClient}>
                <div>Marco</div>
            </InstantSearch>
        </InstantSearchSSRProvider>
    );
};
dhayab commented 2 years ago

Not that I know of. getServerState() relies on rendering your app server-side to allow InstantSearch to initialize and retrieve the initial results.

Replicating that would probably require to:

hideokamoto commented 2 years ago

Possibly I resolved a similar issue. Are you using the useRouter() hook on the app?

During the debugging of this error, I found the following error message additionaly:

error - TypeError: Cannot destructure property 'locale' of 'router' as it is null.

So I updated my code like this:

-   const { locale } =useRouter()
+  const router = useRouter()
+  const { locale } = router || {}

After saving this update, the getServerState function works well.

In my thoughts, the useRouter function will return null on the rendering process by the getServerState function.

necheporenko commented 2 years ago

Possibly I resolved a similar issue. Are you using the useRouter() hook on the app?

During the debugging of this error, I found the following error message additionaly:

error - TypeError: Cannot destructure property 'locale' of 'router' as it is null.

So I updated my code like this:

-   const { locale } =useRouter()
+  const router = useRouter()
+  const { locale } = router || {}

After saving this update, the getServerState function works well.

In my thoughts, the useRouter function will return null on the rendering process by the getServerState function.

Yeah, I have the same issue with useRouter. https://codesandbox.io/s/crazy-curie-sdeqdi?file=/components/Panel.tsx:250-283

mrpineapples commented 1 year ago

I've also been running into this issue but I'm using Nextjs (useRouter is not the issue here). Has anyone found a solution? Update: turns out all I had to do was delete my yarn.lock file and reinstall my node_modules for it to work. Looks like babel version was updated, not sure exactly why that fixed it but if anyone is in the same boat try this! Might save you a couple of hours

FabienMotte commented 1 year ago

Closing this issue, thanks for your update!