-
-
Notifications
You must be signed in to change notification settings - Fork 444
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement server-side rendering support (#261)
* Implement toSuspenseSource for React Suspense This accepts a Wonka source and throws a promise if the result is not resolving immediately. This is done for suspensen and will also limit the source to a single result. If the source pushes a value synchronously it is let through and won't throw a promise, which also will subsequently let through more values from the source. * Add suspense mode to Client * Implement an SSR exchange This can be used on the client and the server; On the server it can start caching results, while on the client it retrieves cached results from a rehydrated cache. * Add tests for SSR exchange * Fix placement of toSuspenseSource in Client * Add some smoke tests for SSR * Add additional client-side rehydration test * Add SSR data serialization * Reverse order of cached and forwarded ops in ssrExchange * Update comments in toSuspenseSource * Add check to ssr.test.ts to prevent double operation bug
- Loading branch information
Showing
13 changed files
with
506 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { makeSubject, pipe, map, publish, forEach, Subject } from 'wonka'; | ||
|
||
import { Client } from '../client'; | ||
import { queryOperation, queryResponse } from '../test-utils'; | ||
import { ExchangeIO, Operation } from '../types'; | ||
import { ssrExchange } from './ssr'; | ||
|
||
let forward: ExchangeIO; | ||
let exchangeInput; | ||
let client: Client; | ||
let input: Subject<Operation>; | ||
let output; | ||
|
||
beforeEach(() => { | ||
input = makeSubject<Operation>(); | ||
output = jest.fn(); | ||
forward = ops$ => | ||
pipe( | ||
ops$, | ||
map(output) | ||
); | ||
client = { suspense: true } as any; | ||
exchangeInput = { forward, client }; | ||
}); | ||
|
||
it('caches query results correctly', () => { | ||
output.mockReturnValueOnce(queryResponse); | ||
|
||
const ssr = ssrExchange(); | ||
const [ops$, next] = input; | ||
const exchange = ssr(exchangeInput)(ops$); | ||
|
||
publish(exchange); | ||
next(queryOperation); | ||
|
||
const data = ssr.extractData(); | ||
expect(Object.keys(data)).toEqual(['' + queryOperation.key]); | ||
|
||
expect(data).toEqual({ | ||
[queryOperation.key]: { | ||
data: queryResponse.data, | ||
error: undefined, | ||
}, | ||
}); | ||
}); | ||
|
||
it('resolves cached query results correctly', () => { | ||
const onPush = jest.fn(); | ||
|
||
const ssr = ssrExchange({ | ||
initialState: { [queryOperation.key]: queryResponse as any }, | ||
}); | ||
|
||
const [ops$, next] = input; | ||
const exchange = ssr(exchangeInput)(ops$); | ||
|
||
pipe( | ||
exchange, | ||
forEach(onPush) | ||
); | ||
next(queryOperation); | ||
|
||
const data = ssr.extractData(); | ||
expect(Object.keys(data).length).toBe(1); | ||
expect(output).not.toHaveBeenCalled(); | ||
expect(onPush).toHaveBeenCalledWith(queryResponse); | ||
}); | ||
|
||
it('deletes cached results in non-suspense environments', () => { | ||
client.suspense = false; | ||
const onPush = jest.fn(); | ||
const ssr = ssrExchange(); | ||
|
||
ssr.restoreData({ [queryOperation.key]: queryResponse as any }); | ||
expect(Object.keys(ssr.extractData()).length).toBe(1); | ||
|
||
const [ops$, next] = input; | ||
const exchange = ssr(exchangeInput)(ops$); | ||
|
||
pipe( | ||
exchange, | ||
forEach(onPush) | ||
); | ||
next(queryOperation); | ||
|
||
expect(Object.keys(ssr.extractData()).length).toBe(0); | ||
expect(onPush).toHaveBeenCalledWith(queryResponse); | ||
|
||
// NOTE: The operation should not be duplicated | ||
expect(output).not.toHaveBeenCalled(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import { pipe, share, filter, merge, map, tap } from 'wonka'; | ||
import { Exchange, OperationResult, Operation } from '../types'; | ||
import { CombinedError } from '../utils'; | ||
|
||
export interface SerializedResult { | ||
data?: any; | ||
error?: { | ||
networkError?: string; | ||
graphQLErrors: string[]; | ||
}; | ||
} | ||
|
||
export interface SSRData { | ||
[key: string]: SerializedResult; | ||
} | ||
|
||
export interface SSRExchangeParams { | ||
initialState?: SSRData; | ||
} | ||
|
||
export interface SSRExchange extends Exchange { | ||
/** Rehydrates cached data */ | ||
restoreData(data: SSRData): void; | ||
/** Extracts cached data */ | ||
extractData(): SSRData; | ||
} | ||
|
||
const shouldSkip = ({ operationName }: Operation) => | ||
operationName !== 'subscription' && operationName !== 'query'; | ||
|
||
/** Serialize an OperationResult to plain JSON */ | ||
const serializeResult = ({ | ||
data, | ||
error, | ||
}: OperationResult): SerializedResult => { | ||
const result: SerializedResult = { data, error: undefined }; | ||
if (error !== undefined) { | ||
result.error = { | ||
networkError: '' + error.networkError, | ||
graphQLErrors: error.graphQLErrors.map(x => '' + x), | ||
}; | ||
} | ||
|
||
return result; | ||
}; | ||
|
||
/** Deserialize plain JSON to an OperationResult */ | ||
const deserializeResult = ( | ||
operation: Operation, | ||
result: SerializedResult | ||
): OperationResult => { | ||
const { error, data } = result; | ||
const deserialized: OperationResult = { operation, data, error: undefined }; | ||
if (error !== undefined) { | ||
deserialized.error = new CombinedError({ | ||
networkError: new Error(error.networkError), | ||
graphQLErrors: error.graphQLErrors, | ||
}); | ||
} | ||
|
||
return deserialized; | ||
}; | ||
|
||
/** The ssrExchange can be created to capture data during SSR and also to rehydrate it on the client */ | ||
export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => { | ||
const data: SSRData = {}; | ||
|
||
const isCached = (operation: Operation) => { | ||
return !shouldSkip(operation) && data[operation.key] !== undefined; | ||
}; | ||
|
||
// The SSR Exchange is a temporary cache that can populate results into data for suspense | ||
// On the client it can be used to retrieve these temporary results from a rehydrated cache | ||
const ssr: SSRExchange = ({ client, forward }) => ops$ => { | ||
const sharedOps$ = share(ops$); | ||
|
||
let forwardedOps$ = pipe( | ||
sharedOps$, | ||
filter(op => !isCached(op)), | ||
forward | ||
); | ||
|
||
// NOTE: Since below we might delete the cached entry after accessing | ||
// it once, cachedOps$ needs to be merged after forwardedOps$ | ||
let cachedOps$ = pipe( | ||
sharedOps$, | ||
filter(op => isCached(op)), | ||
map(op => { | ||
const serialized = data[op.key]; | ||
return deserializeResult(op, serialized); | ||
}) | ||
); | ||
|
||
if (client.suspense) { | ||
// Inside suspense-mode we cache results in the cache as they're resolved | ||
forwardedOps$ = pipe( | ||
forwardedOps$, | ||
tap((result: OperationResult) => { | ||
const { operation } = result; | ||
if (!shouldSkip(operation)) { | ||
const serialized = serializeResult(result); | ||
data[operation.key] = serialized; | ||
} | ||
}) | ||
); | ||
} else { | ||
// Outside of suspense-mode we delete results from the cache as they're resolved | ||
cachedOps$ = pipe( | ||
cachedOps$, | ||
tap((result: OperationResult) => { | ||
delete data[result.operation.key]; | ||
}) | ||
); | ||
} | ||
|
||
return merge([forwardedOps$, cachedOps$]); | ||
}; | ||
|
||
ssr.restoreData = (restore: SSRData) => Object.assign(data, restore); | ||
ssr.extractData = () => Object.assign({}, data); | ||
|
||
if (params && params.initialState) { | ||
ssr.restoreData(params.initialState); | ||
} | ||
|
||
return ssr; | ||
}; |
Oops, something went wrong.