Skip to content

Instantly share code, notes, and snippets.

@UberMouse
Created April 22, 2020 00:11
Show Gist options
  • Save UberMouse/ca0d0fe8f8562c776bc0ed9e67d5db8b to your computer and use it in GitHub Desktop.
Save UberMouse/ca0d0fe8f8562c776bc0ed9e67d5db8b to your computer and use it in GitHub Desktop.
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