Created
April 22, 2020 00:11
-
-
Save UberMouse/ca0d0fe8f8562c776bc0ed9e67d5db8b to your computer and use it in GitHub Desktop.
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
import { useMachine } from "@xstate/react/lib"; | |
import { GraphQLError } from "graphql"; | |
import { values } from "lodash"; | |
import React, { useEffect, useRef } from "react"; | |
import { Observable } from "rxjs"; | |
import { map } from "rxjs/operators"; | |
import { assign, createMachine } from "xstate"; | |
import { QueryResultContainer, QueryWrapper } from "../gql/queryResultHandler"; | |
import { $YesReallyAny, assert } from "../index"; | |
import { makeMachineRoutable } from "./makeMachineRoutable"; | |
type DataBridgeContext<TData, TParameters> = { | |
parameters?: TParameters; | |
lastResult?: TData; | |
message?: string; | |
actions?: { | |
[transitionedTo in DataBridgeState<TData, TParameters>["value"]]?: ( | |
ctx: Extract<DataBridgeState<TData, TParameters>, { value: transitionedTo }>["context"] | |
) => void; | |
}; | |
graphqlQueryMethods?: QueryWrapper<TData, TParameters>; | |
errors?: readonly GraphQLError[]; | |
loadingEnteredAt?: number; | |
}; | |
type DataBridgeEvents<TData, TParameters> = | |
| { | |
type: "DATA_RECEIVED"; | |
data: TData; | |
graphqlQueryMethods: QueryWrapper<TData, TParameters>; | |
} | |
| { type: "ERROR"; errors: readonly GraphQLError[] } | |
| { type: "REFETCHING"; parameters: TParameters }; | |
type DataBridgeState<TData, TParameters> = | |
| { | |
value: "loading"; | |
context: DataBridgeContext<TData, TParameters>; | |
} | |
| { value: "loaded"; context: DataBridgeContext<TData, TParameters> & { lastResult: TData } } | |
| { | |
value: "loaded.refetching"; | |
context: DataBridgeContext<TData, TParameters> & { lastResult: TData }; | |
} | |
// TODO: Fill in this state in the machine | |
| { value: "error"; context: DataBridgeContext<TData, TParameters> & { message: string } }; | |
const actions = { | |
loading: "loadingEntered", | |
loaded: "loadedEntered", | |
error: "errorEntered", | |
storeData: "storeData", | |
storeError: "storeError", | |
storeLoadingEntered: "storeLoadingEntered", | |
updateContext: "updateContext" | |
} as const; | |
const delays = { | |
loadTimeout: "loadTimeout" | |
} as const; | |
const services = { | |
getGqlObservable: "getGqlObservable" | |
} as const; | |
export function createGqlDataBridge< | |
TObservableFactory extends ( | |
...args: $YesReallyAny[] | |
) => QueryResultContainer<$YesReallyAny, $YesReallyAny, TData>, | |
TData = ReturnType<TObservableFactory> extends QueryResultContainer< | |
$YesReallyAny, | |
$YesReallyAny, | |
infer TQueryResult | |
> | |
? TQueryResult | |
: never, | |
TParameters = Parameters<TObservableFactory>, | |
TUnwrappedParameters = TParameters extends Array<infer U> ? U : never, | |
TProviderProps = TParameters extends [] | |
? { parameters?: TParameters; actions?: DataBridgeContext<TData, TParameters>["actions"] } | |
: { | |
parameters: TUnwrappedParameters; | |
actions?: DataBridgeContext<TData, TParameters>["actions"]; | |
} | |
>(observableFactory: TObservableFactory) { | |
const name = observableFactory.name; | |
const gqlDataBridgeMachine = createMachine< | |
DataBridgeContext<TData, TParameters>, | |
DataBridgeEvents<TData, TParameters>, | |
DataBridgeState<TData, TParameters> | |
>( | |
{ | |
id: "gqlDataBridge", | |
initial: "loadingInvisible", | |
invoke: { | |
src: services.getGqlObservable | |
}, | |
states: { | |
loadingInvisible: { | |
on: { | |
ERROR: { | |
target: "error", | |
actions: actions.storeError | |
}, | |
DATA_RECEIVED: { | |
target: "loaded", | |
actions: actions.storeData | |
} | |
}, | |
after: { | |
300: "loading" | |
} | |
}, | |
/** | |
* The loading state is used for displaying loaders. It's entered after the machine has been in the | |
* "pre loading" state for 300ms. This is so that very short loads don't result in the loading UI being displayed | |
* | |
* To prevent a very short flash of the loading state (say it finished loading 310ms after starting, the loading | |
* state would be displayed for 10ms) the loading state consists of waitingForData and waitingForTimeout | |
* | |
* When the machine enters the loading state it captures the time when that happens, if once the data has arrived | |
* the loading UI has been displayed for less than 300ms it delays transitioning to the loading state by the | |
* remaining time required to have it displayed for 300ms. | |
* | |
* This ensures that the loading state is always displayed for at _least_ 300ms. It can add extra loading time | |
* to things that are fast, but not super fast, but it's still very short so it seemed like an acceptable trade off | |
*/ | |
loading: { | |
entry: [actions.loading, actions.storeLoadingEntered], | |
initial: "waitingForData", | |
on: { | |
ERROR: { | |
target: "#gqlDataBridge.error", | |
actions: actions.storeError | |
} | |
}, | |
states: { | |
waitingForData: { | |
on: { | |
DATA_RECEIVED: { | |
target: "waitingForTimeout", | |
actions: actions.storeData | |
} | |
} | |
}, | |
waitingForTimeout: { | |
after: [ | |
{ | |
delay: delays.loadTimeout, | |
target: "#gqlDataBridge.loaded" | |
} | |
] | |
} | |
} | |
}, | |
error: { | |
entry: "errorEntered" | |
}, | |
loaded: { | |
entry: actions.loaded, | |
on: { | |
DATA_RECEIVED: { | |
actions: [actions.storeData, actions.loaded], | |
target: ".idle" | |
}, | |
REFETCHING: { | |
target: "loaded.refetching", | |
actions: actions.updateContext, | |
internal: true | |
} | |
}, | |
initial: "idle", | |
states: { | |
idle: {}, | |
refetching: { | |
on: { | |
ERROR: { | |
target: "#gqlDataBridge.error", | |
actions: actions.storeError | |
} | |
} | |
} | |
} | |
}, | |
finished: { | |
type: "final" | |
} | |
} | |
}, | |
{ | |
// I'm unaware of any way to enforce with the type system these actions will only be called in the correct state | |
// The types of the action at the callsite are correct however so just @ts-ignore it | |
actions: { | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
[actions.loading]: ctx => ctx.actions?.loading?.(ctx), | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
[actions.loaded]: ctx => ctx.actions?.loaded?.(ctx), | |
[actions.error]: ctx => { | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
ctx.actions?.error?.(ctx); | |
}, | |
[actions.storeData]: assign({ | |
lastResult: (_ctx, event) => { | |
assert(event.type === "DATA_RECEIVED"); | |
return event.data; | |
}, | |
graphqlQueryMethods: (_ctx, event) => { | |
assert(event.type === "DATA_RECEIVED"); | |
return event.graphqlQueryMethods; | |
} | |
}), | |
[actions.storeError]: assign({ | |
errors: (ctx, e) => (e.type === "ERROR" ? e.errors : ctx.errors) | |
}), | |
[actions.storeLoadingEntered]: assign<DataBridgeContext<TData, TParameters>>({ | |
loadingEnteredAt: () => performance.now() | |
}), | |
[actions.updateContext]: assign< | |
DataBridgeContext<TData, TParameters>, | |
DataBridgeEvents<TData, TParameters> | |
>({ | |
parameters: (_ctx, e) => { | |
assert(e.type === "REFETCHING"); | |
return e.parameters; | |
} | |
}) | |
}, | |
delays: { | |
[delays.loadTimeout]: ctx => { | |
const timeSinceLoadingStateEntered = performance.now() - (ctx.loadingEnteredAt ?? 0); | |
const minimumLoadingTime = 300; | |
return Math.max(0, minimumLoadingTime - timeSinceLoadingStateEntered); | |
} | |
}, | |
services: { | |
[services.getGqlObservable]: (ctx): Observable<DataBridgeEvents<TData, TParameters>> => { | |
console.debug(`[${name}]: creating Apollo observable with parameters: `, ctx.parameters); | |
const queryContainer = ctx.parameters | |
? observableFactory(ctx.parameters) | |
: observableFactory(); | |
const { query, results } = queryContainer; | |
const resultsForMachine = results.pipe( | |
map(r => { | |
if (r.type === "error") { | |
console.log(r, "ERROR"); | |
return { | |
type: "ERROR" as const, | |
errors: r.errors | |
}; | |
} | |
log("data received: ", r.data); | |
return { | |
type: "DATA_RECEIVED" as const, | |
data: r.data, | |
graphqlQueryMethods: query | |
}; | |
}) | |
); | |
return resultsForMachine; | |
} | |
} | |
} | |
); | |
const { Match, rawProvider: Provider } = makeMachineRoutable(gqlDataBridgeMachine); | |
const DataBridgeProvider: React.FC<TProviderProps> = props => { | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
const parameters = props.parameters; | |
const [current, send, service] = useMachine(gqlDataBridgeMachine, { | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
context: { parameters, actions: props.actions } | |
}); | |
const firstRender = useRef(true); | |
useEffect(() => { | |
if (firstRender.current) { | |
firstRender.current = false; | |
return; | |
} | |
const { graphqlQueryMethods } = current.context; | |
if (graphqlQueryMethods) { | |
console.log("refetching query", parameters); | |
send({ type: "REFETCHING", parameters }); | |
graphqlQueryMethods.refetch(parameters); | |
} | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, [...values(parameters)]); | |
useEffect(() => { | |
let oldValue: unknown = gqlDataBridgeMachine.initial; | |
service.onTransition(state => { | |
log("transitioning:", oldValue, "->", state.value, state.context); | |
oldValue = state.value; | |
}); | |
service.onEvent(e => log("handling:", e)); | |
}, [service]); | |
return <Provider value={[send, current, service]}>{props.children}</Provider>; | |
}; | |
const log: typeof console.debug = (...params: $YesReallyAny[]) => { | |
console.debug(`[gqlBridge(${name})]`, ...params); | |
}; | |
return { | |
Match, | |
Provider: DataBridgeProvider | |
}; | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment