Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSR with RTK and RTK Query on a custom framework #2188

Closed
kdevan opened this issue Mar 31, 2022 · 1 comment
Closed

SSR with RTK and RTK Query on a custom framework #2188

kdevan opened this issue Mar 31, 2022 · 1 comment

Comments

@kdevan
Copy link

kdevan commented Mar 31, 2022

This is using React 18, RTK beta, and cross-fetch with Webpack 5.

I've been digging into SSR for a few days now and have it mostly working but could use some help to clarify how the implementation should look from a higher level. Meaning, how server render looks, then hydration, then client taking over, that whole flow. All of the examples I could find expect the use of Next.js or Redux Persist or something that already has hydration built in (which of course makes sense to have examples with popular libraries). But for those of us rolling our own hydration...

I've started with the Redux Server Rendering Guide (https://redux.js.org/usage/server-rendering). That has been working really well.

Next I've gone through the RTK Query SSR guide (https://redux-toolkit.js.org/rtk-query/usage/server-side-rendering). That's gone well too but adding your own hydration to this example is where it starts to get tricky.

The flow I have so far looks something like this.

Server

  1. Get preloaded data from webpack/node build process - from json file
  2. Dynamically import the RTK Query API - api file variable name from preloaded data
  3. Initialize store - preloaded data gets passed here to become initial data
  4. Use store to dispatch the initialize RTK Query API Queries
  5. api.getRunningOperationPromises
  6. renderToString
  7. api.resetApiState
  8. Return HTML string as server Response

Client

  1. Get preloaded data from build - from window.__INITIAL_DATA__
  2. Initialize store
  3. Dispatch hydration action
  4. Cleanup - delete window.__INITIAL_DATA__
  5. Return <App />

Render

  1. Dynamically import the RTK Query API - api file variable name from window.__INITIAL_DATA__.state.api
  2. If server then hydrateRoot
  3. If client then createRoot

The code itself looks something like this. This is pseudo-code to simplify so for sure there's obvious broken parts but the idea is just to get a grip on the overall flow. I put links below this to credit the parts of this code that have come from previous issue's discussions.

hooks.ts

export function useIsomorficEffect () {
  const isFirstRun = useRef(true)

  useEffect(() => {
    if (isFirstRun.current) {
      isFirstRun.current = false
      return
    }
    return () => {
      isFirstRun.current = true
    }
  }, [])

  return isFirstRun.current
}

hydrate.ts

export const hydrate = createAction<DeepPartial<any>>('hydrate');

const reducerWithHydrate = (preloadedState: DeepPartial<any>, combinedReducer: Reducer<any>) => {
  return createReducer(preloadedState, (builder) => {
    builder
      .addCase(hydrate, (state: RootState, action: AnyAction) => {
        if (!action.isInitialHydrate) {
          return state;
        }
        const nextState: RootState = { ...state, ...action.payload }

        // Exclude anything that's purely client state here by overriding what came from the action
        // if (state.stuff) nextState.stuff = state.stuff;

        return nextState;
      })
      .addDefaultCase((state, action) => combinedReducer(state, action));
  });
};

fetch.ts

import { fetch, Headers, Request, Response } from 'cross-fetch';

Object.assign(globalThis, {
  fetch: fetch,
  Headers: Headers,
  Request: Request,
  Response: Response,
  AbortController: AbortController,
});

api.ts

import fetch from 'cross-fetch';
import './fetch';

const api = ({ url }: { url: string }) => createApi({
  reducerPath: api,
  baseQuery: fetchBaseQuery({
    baseUrl: url,
    fetchFn: fetch,
  }),
  tagTypes: ['contents'],
  endpoints: (build) => ({
    getContent: build.query({
      query: ({ contentId }) => `content/${contentId}`,
      providesTags: (result: any, error: any, { contentId }) => [{ type: 'contents' as const, id: contentId }]
    }),
  }),
  extractRehydrationInfo(action, { reducerPath }) {
    if (action.type === hydrate.toString()) {
      return action.payload[reducerPath];
    }
  }
});

store.ts

export const initializeStore = (preloadedState: DeepPartial<any> = {}, api: any) => {
  const combinedReducer = combineReducers({
    stuff: preloadedState.stuff,
    [api.reducerPath]: api.reducer
  });

  const store = configureStore({
    reducer: reducerWithHydrate(
      preloadedState,
      combinedReducer
    ),
    middleware: (getDefaultMiddleware) => 
      getDefaultMiddleware()
      .concat(api.middleware),
    devTools: true,
    preloadedState,
  });

  setupListeners(store.dispatch);
  
  return store;
}

export type AppStore = ReturnType<typeof initializeStore>;

export type RootState = ReturnType<AppStore['getState']>;

export type AppDispatch = AppStore['dispatch'];

server.ts

const preloadedState = stateFromBuild();

const api = await import(./${preloadedState.api});

const store = initializeStore(
    preloadedState,
    api
);

contents.map((content) => store.dispatch(api.endpoints.getContent.initiate(content.id)));

await Promise.all(api.util.getRunningOperationPromises());

const html = renderToString(App);

const initialState = store.getState();

store.dispatch(api.util.resetApiState());

return generateHtml(html, initialState);

App.client.tsx

const ClientApp = ({api}: {api: any}): JSX.Element => {
  const isInitialHydrate = useIsomorficEffect();

  const store = initializeStore(
    (window as any).__INITIAL_DATA__.state,
    api
  );
  
  store.dispatch({
    type: hydrate.toString(),
    payload: (window as any).__INITIAL_DATA__.state,
    isInitialHydrate: isInitialHydrate
  });

  delete (window as any).__INITIAL_DATA__;

  return (
    <StrictMode>
      <ErrorBoundary FallbackComponent={Error}>
        <Provider store={store}>
          <App />
        </Provider>
      </ErrorBoundary>
    </StrictMode>
  );
};

entry.client.tsx - this is passed to webpack entry for client build

const handleRender = async (Component: any) => {
  const api = await import(./${(window as any).__INITIAL_DATA__.state.api});

  process.env.IS_SERVER
    ? hydrateRoot(
          document.getElementById('app'),
          <Component api={api} />
        )
    : createRoot(
        document.getElementById('app')
      )
      .render(
        <Component api={api} />
      );
};

handleRender(ClientApp);

In this case, hydration happens once when the client first renders from the server. After that when client routing takes over, hydration gets skipped and the rest of the store/state takes over as combinedReducers. I believe this is similar to the next.js way except for that client routing never takes over since nextjs SSRs every page.

There is lots of parts of this flow that I'm unsure of so any guidance is much appreciated.

Some resources I've found really useful:

And of course:

Some questions I have are:

  1. Continuing with the Redux and RTK server rendering guides, at what points in the App should hydration be dispatched and under what conditions? In next.js it looks like shouldComponentUpdate triggers the dispatching of the hydration action at the _app and page level. Is there anywhere else it should be dispatched? How do we mimic shouldComponentUpdate for this case, without class components, in a more functional/hooks way? Instead of a HOC, manually placing these calls is fine but I'm not entirely sure where it/they should go.
  2. What are the key points in server, render (as in webpack entry), and client that should have some Redux related functionality for handling SSR/hydration?
  3. Does server and client in the "state-reconciliation-during-hydration" part of next-redux-wrapper refer to server being api/fetch/query cached calls and client being then simply the pure app/local/client data?

That is my best understanding of this SSR/hydration flow so far. After this point is a case-specific (i think) issue I've run into that may or may not end up having to do with RTK.

Where I'm at now, after I build the app with webpack and load the app for the first time, server side rendering works perfectly. When I refresh the page (even though it should be loading from the server the same way as the first time.. afaik), I get a flash of "error" in each component that's doing a query, followed by the queries re-fetching and then the page rendering correctly again. But the flash of "error" is a totally broken page until the queries finish again. I'm not sure why that's happening or if it's even related to this general SSR/hydration flow (could be on the cross-fetch side of things?) but right now I'm so unsure about so many parts of this that any guidance here to help me grasp this process will help regardless.

When I log the action.payload in the hydrate action reducer case during the refresh, it shows that the queries each have an error:

error: "TypeError: Only absolute URLs are supported"
status: "FETCH_ERROR"

Whereas for the first load, the queries successfully return data and correctly show up as rejected in Redux Dev Tools Inspector (since they are cached).

When I print the URL I'm passing, it is the full URL so I'm not sure why it's saying that. I have tested that on client and server (I use webpack env plugin to pass the URL as an environment variable to the client and the environment variable seems to print everywhere). I've also tried to pass the URL as a normal hard coded string but still receive this error.

So anyways, I thought I'd take a step back and try to understand this from a higher level to see if it leads me to what might be causing this. Even if this ends up being too case-specific to give an answer to, I thought writing this out would help me think this over and also might help jump start someone else heading down the same road :)

@kdevan
Copy link
Author

kdevan commented Apr 5, 2022

I switched from cross-fetch to node-fetch with a similar implementation to: https://github.com/phryneas/ssr-experiments/blob/main/nextjs-blog/pages/_app.js

The error I was running into does not appear anymore and subsequent refreshes of the page act accordingly.

@kdevan kdevan closed this as completed Apr 5, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant