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

Feature/rtkq ssr #1270

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/toolkit/etc/rtk-query.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type Api<BaseQuery extends BaseQueryFn, Definitions extends EndpointDefin
[K in keyof NewDefinitions]?: Partial<NewDefinitions[K]> | ((definition: NewDefinitions[K]) => void);
} : never;
}): Api<BaseQuery, ReplaceTagTypes<Definitions, TagTypes | NewTagTypes>, ReducerPath, TagTypes | NewTagTypes, Enhancers>;
ssr?: boolean;
};

// @public (undocumented)
Expand Down Expand Up @@ -58,6 +59,7 @@ export interface CreateApiOptions<BaseQuery extends BaseQueryFn, Definitions ext
refetchOnMountOrArgChange?: boolean | number;
refetchOnReconnect?: boolean;
serializeQueryArgs?: SerializeQueryArgs<BaseQueryArg<BaseQuery>>;
ssr?: boolean;
tagTypes?: readonly TagTypes[];
}

Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/query/apiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,5 @@ export type Api<
TagTypes | NewTagTypes,
Enhancers
>
ssr: Record<string, any>
}
22 changes: 12 additions & 10 deletions packages/toolkit/src/query/createApi.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import type { Api, ApiContext, Module, ModuleName } from './apiTypes'
import type { BaseQueryArg, BaseQueryFn } from './baseQueryTypes'
import type {
SerializeQueryArgs} from './defaultSerializeQueryArgs';
import {
defaultSerializeQueryArgs
} from './defaultSerializeQueryArgs'
import type { SerializeQueryArgs } from './defaultSerializeQueryArgs'
import { defaultSerializeQueryArgs } from './defaultSerializeQueryArgs'
import type {
EndpointBuilder,
EndpointDefinitions} from './endpointDefinitions';
import {
DefinitionType
EndpointDefinitions,
} from './endpointDefinitions'
import { DefinitionType } from './endpointDefinitions'

export interface CreateApiOptions<
BaseQuery extends BaseQueryFn,
Expand Down Expand Up @@ -100,10 +96,10 @@ export interface CreateApiOptions<
): Definitions
/**
* Defaults to `60` _(this value is in seconds)_. This is how long RTK Query will keep your data cached for **after** the last component unsubscribes. For example, if you query an endpoint, then unmount the component, then mount another component that makes the same request within the given time frame, the most recent value will be served from the cache.
*
*
* ```ts
* // codeblock-meta title="keepUnusedDataFor example"
*
*
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
* interface Post {
* id: number
Expand Down Expand Up @@ -150,6 +146,10 @@ export interface CreateApiOptions<
* Note: requires [`setupListeners`](./setupListeners) to have been called.
*/
refetchOnReconnect?: boolean
/**
* Defaults to `false`. This setting allows to enable query execution on the server
*/
ssr?: boolean
}

export type CreateApi<Modules extends ModuleName> = {
Expand Down Expand Up @@ -196,6 +196,7 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
refetchOnMountOrArgChange: false,
refetchOnFocus: false,
refetchOnReconnect: false,
ssr: false,
...options,
tagTypes: [...(options.tagTypes || [])],
}
Expand Down Expand Up @@ -233,6 +234,7 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
}
return api
},
ssr: optionsWithDefaults.ssr ? {} : undefined,
} as Api<BaseQueryFn, {}, string, string, Modules[number]['name']>

const initializedModules = modules.map((m) =>
Expand Down
28 changes: 28 additions & 0 deletions packages/toolkit/src/query/react/buildHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,34 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
})

const promiseRef = useRef<QueryActionCreatorResult<any>>()
if (
typeof api.ssr === 'object' &&
!(
api.ssr[api.reducerPath] &&
api.ssr[api.reducerPath][name] &&
api.ssr[api.reducerPath][name][JSON.stringify(stableArg)]
)
) {
// ssr mode is on and there is no promise yet
const promise = dispatch(
Copy link

@Ephem Ephem Jul 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just leaving this comment to point out to anyone not used to SSR that this dispatch is the necessary "hack" to make this whole approach work. Since useEffect doesn't run on the server, when in ssr mode this PR would make the dispatch fire directly in render instead, breaking the no observable side effects in render rule. The new SSR renderer in React 18 will be pretty likely to break/break with this approach (and indeed any approach that fetches data from within the tree).

Because of how SSR works pre React 18, this is pretty much the only way to do it if you want to support fetching data from within the component tree, instead of only ahead of rendering. So while not an uncommon approach, I wanted to clearly mark out the "hacky part" to others. 😄

(I'll leave some more thoughts and comments on the linked issue instead of here on the PR since I think they fit better there. Update: Comment added here)

initiate(stableArg, {
subscriptionOptions: stableSubscriptionOptions,
forceRefetch: refetchOnMountOrArgChange,
})
)

if (!(typeof api.ssr[api.reducerPath] === 'object')) {
api.ssr[api.reducerPath] = {}
api.ssr[api.reducerPath][name] = {}
}

console.warn('ssr cache', {
ssr: api.ssr,
query: api.ssr[api.reducerPath][name][JSON.stringify(stableArg)],
})

api.ssr[api.reducerPath][name][JSON.stringify(stableArg)] = promise
}

useEffect(() => {
const lastPromise = promiseRef.current
Expand Down
101 changes: 101 additions & 0 deletions packages/toolkit/src/query/react/getDataFromTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'

import type { Dispatch } from 'react'
import type { AnyAction, Middleware, Store } from 'redux'

export async function getDataFromTree(
storeFn: (middleWare: Middleware) => Store,
App: ({ store }: { store: Store }) => JSX.Element,
renderFn = renderToStaticMarkup
) {
const middlewareControls = createQueryMiddleWare()
const store = storeFn(middlewareControls.middleware)
try {
await _getDataFromTree(middlewareControls, () =>
renderFn(<App store={store} />)
)
} catch (e) {
console.log(e)
}

return store
}

function createQueryMiddleWare() {
let resolve: ((v?: unknown) => void) | null = null
let reject: ((v?: unknown) => void) | null = null
let onQueryStarted: (querySet: Set<String>) => void = () => undefined

const state = {
pendingQueries: new Set<String>(),
renderComplete: false,
queryStarted: false,
promise: new Promise((res, rej) => {
resolve = res
reject = rej
}),
}

const timeout = setTimeout(() => {
reject?.()
}, 5000)

return {
renderStart: () => {
state.queryStarted = false
state.renderComplete = false
},
renderDone: () => {
state.renderComplete = true
state.promise = new Promise((res, rej) => {
resolve = res
reject = rej
})

if (state.pendingQueries.size === 0) {
resolve?.()
}
},
onQueryStarted: (callback: (querySet?: Set<String>) => void) => {
onQueryStarted = callback
},
middleware: () => (next: Dispatch<AnyAction>) => (action: AnyAction) => {
const result = next(action)

if (typeof action.type === 'string') {
if (action.type.endsWith('executeQuery/pending')) {
state.pendingQueries.add(action.meta.requestId)
onQueryStarted(state.pendingQueries)
} else if (
action.type.endsWith('executeQuery/fulfilled') ||
action.type.endsWith('executeQuery/rejected')
) {
state.pendingQueries.delete(action.meta.requestId)
if (state.renderComplete && state.pendingQueries.size === 0) {
clearTimeout(timeout)
resolve?.()
}
}
}
return result
},
state,
}
}

async function _getDataFromTree(
middlewareControls: ReturnType<typeof createQueryMiddleWare>,
renderFn: () => void
): Promise<any> {
let queryStarted = false
middlewareControls.onQueryStarted(() => (queryStarted = true))
middlewareControls.renderStart()
renderFn()
middlewareControls.renderDone()
await middlewareControls.state.promise

if (queryStarted) {
return _getDataFromTree(middlewareControls, renderFn)
}
}
3 changes: 2 additions & 1 deletion packages/toolkit/src/query/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { coreModule, buildCreateApi, CreateApi } from '@reduxjs/toolkit/query'
import { reactHooksModule, reactHooksModuleName } from './module'
import { getDataFromTree } from './getDataFromTree'

import type { MutationHooks, QueryHooks } from './buildHooks'
import type {
Expand All @@ -21,4 +22,4 @@ const createApi = /* @__PURE__ */ buildCreateApi(
reactHooksModule()
)

export { createApi, reactHooksModule }
export { createApi, reactHooksModule, getDataFromTree }
107 changes: 107 additions & 0 deletions packages/toolkit/src/query/tests/getDataFromTree.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as React from 'react'
import { Provider } from 'react-redux'
import { renderToString } from 'react-dom/server'

import type { ReactNode } from 'react'
import type { Middleware, Store } from 'redux'

import { createApi, fakeBaseQuery, getDataFromTree } from '../react'

import { configureStore } from '@internal/configureStore'

describe('getDataFromTree walks the tree and collects the data in the store', () => {
const testApi = createApi({
baseQuery: fakeBaseQuery(),
endpoints: (build) => ({
withQueryFn: build.query({
queryFn(arg: string) {
return { data: `resultFrom(${arg})` }
},
}),
}),
ssr: true,
})

const storeFn = (middleware: Middleware) =>
configureStore({
reducer: { [testApi.reducerPath]: testApi.reducer },
middleware: (gDM) => gDM({}).concat([testApi.middleware, middleware]),
})

const QueryComponent = ({
queryString,
children,
}: {
queryString: string
children?: ReactNode
}) => {
const { data } = testApi.useWithQueryFnQuery(queryString)

if (!data) {
return null
}

return (
<div>
{JSON.stringify(data)}
<div>{children}</div>
</div>
)
}

const TestApp = ({ store }: { store: Store }) => {
return (
<Provider store={store}>
<QueryComponent queryString="top">
<QueryComponent queryString="nested" />
</QueryComponent>
</Provider>
)
}
it('resolves all the data', async () => {
const store = await getDataFromTree(storeFn, TestApp)

const html = renderToString(<TestApp store={store} />)

expect(html).toMatchInlineSnapshot(
`"<div>&quot;resultFrom(top)&quot;<div><div>&quot;resultFrom(nested)&quot;<div></div></div></div></div>"`
)
expect(store.getState()).toStrictEqual({
api: {
config: {
focused: true,
keepUnusedDataFor: 60,
middlewareRegistered: true,
online: true,
reducerPath: 'api',
refetchOnFocus: false,
refetchOnMountOrArgChange: false,
refetchOnReconnect: false,
},
mutations: {},
provided: {},
queries: {
'withQueryFn("nested")': {
data: 'resultFrom(nested)',
endpointName: 'withQueryFn',
fulfilledTimeStamp: expect.any(Number),
originalArgs: 'nested',
requestId: expect.any(String),
startedTimeStamp: expect.any(Number),
status: 'fulfilled',
},
'withQueryFn("top")': {
data: 'resultFrom(top)',
endpointName: 'withQueryFn',
fulfilledTimeStamp: expect.any(Number),
originalArgs: 'top',
requestId: expect.any(String),
startedTimeStamp: expect.any(Number),
status: 'fulfilled',
},
},
subscriptions: expect.any(Object),
},
})
})
})