/**
* This file contains the loader for html resources that lay within
* the application and are part of its buildnumber (aka static) with caching
* functionality
*
* TODO this is incomplete as the ssr context is not properly collecting
*
* @module
*/
import type Root from "../../../base/Root";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ISSRContextType, SSRContext } from "../../internal/providers/ssr-provider";
import { useRootRetriever } from "../root/RootRetriever";
import type { IAppDataType } from "../../../server";
import type { IResourceCollectionResult } from "../../../server/ssr/collect";
import { TokenContext } from "../../internal/providers/token-provider";
import { SSRError } from "../../../client/providers/item/hook";
const SSR_GRACE_TIME = 10000;
const LOAD_TIME = (new Date()).getTime();
export interface IResourceLoaderOptions {
/**
* An arbitrary id for the resource to load
* a change of id will cause the resource to reload
* normally it only triggers on path or src changes
* as that is its assumed id, use this to do otherwise
*/
id?: string;
/**
* Includes the token in the header for usage in validation as token
*
* NOTE changing this property will not re-resolve please use id instead
*/
includeToken?: boolean;
/**
* the source path as a string, by default it is /rest/resource/
*/
path?: string;
/**
* The server side resolver
* to realize how this is resolved we need to support resources
* in our SSR and request manager
*
* does not support binary types
*
* NOTE changing this property will not re-resolve please use id instead
*/
serverSideResolver?: (appData: IAppDataType, fullsrc: string) => IResourceCollectionResult | Promise;
/**
* A client side resolver to resolve instead of using fetch
*
* does not support binary types
*
* NOTE changing this property will not re-resolve please use id instead
*/
clientSideResolver?: (fullsrc: string) => string | Promise;
/**
* the source as a string, without the /rest/resource/ part, which is
* defined in the path
*/
src?: string;
/**
* sw cacheable flag, defaults to true
*
* NOTE changing this property will not re-resolve please use id instead
*/
swCacheable?: boolean;
/**
* sw network first flag, defaults to false
*
* NOTE changing this property will not re-resolve please use id instead
*/
swNetworkFirst?: boolean;
/**
* sw recheck flag, rechecks the content after done, defaults to false
*
* NOTE changing this property will not re-resolve please use id instead
*/
swRecheck?: boolean;
/**
* Causes the resource to be requested with pooling
*
* NOTE changing this property will not re-resolve please use id instead
*/
pollEvery?: number;
/**
* The content will not change during loading
* and it will be mantained as value so that the
* screen does not blink
*
* NOTE changing this property will not re-resolve please use id instead
*/
keepContentDuringLoading?: boolean;
/**
* Type to load
*
* NOTE changing this property will not re-resolve please use id instead
*/
type: "text" | "json" | "binary-blob" | "binary-arraybuffer";
}
export interface IResourceLoaderHookValue {
data: any;
loading: boolean;
failed: boolean;
}
/**
* The props for the html resource loader
*/
export interface IResourceLoaderProps extends IResourceLoaderOptions {
/**
* To define how the data is to be used
*/
children: (data: any, loading: boolean, failed: boolean) => React.ReactNode;
}
/**
* The actual props need access to the SSR context as resources can indeed be loaded in the server
* side to they immediately execute
*/
interface IActualResourceLoaderProps extends IResourceLoaderProps {
ssrContext: ISSRContextType;
root: Root;
token: string;
}
/**
* Now we get the state, which is the html content
* whether this one is loading or it has failed
*/
interface IActualResourceLoaderState {
content: any;
loading: boolean;
failed: boolean;
}
function construct(props: IResourceLoaderOptions, ssrContext: ISSRContextType, root: Root) {
if (props.serverSideResolver && (props.type === "binary-blob" || props.type === "binary-arraybuffer")) {
throw new Error("Used a ResourceLoader with a " + props.type + " resource with a server side resolver");
}
if (props.clientSideResolver && (props.type === "binary-blob" || props.type === "binary-arraybuffer")) {
throw new Error("Used a ResourceLoader with a " + props.type + " resource with a client side side resolver");
}
// so first we need to see if it's in our ssr context
let content: string = null;
// if we have a ssr context at all that is
if (root.hasRequestManager() || ssrContext) {
let path = props.path || "/rest/resource/";
if (!path.endsWith("/")) {
path += "/";
}
const actualSrc = path + (props.src[0] === "/" ? props.src.substr(1) : props.src);
// and try to set that as the value if we find it
content = (root.hasRequestManager() ? root.getRequestManagerResource(actualSrc) : ssrContext.resources[actualSrc]) || null;
if (props.type === "json") {
content = JSON.parse(content);
}
}
return content;
}
async function doLoad(
options: IResourceLoaderOptions,
ssrContext: ISSRContextType,
token: string,
setState: (v: Partial) => void,
isUnmounted: {
readonly current: boolean;
},
pollIntervalSetAt: {
current: number;
},
pollInterval: {
current: NodeJS.Timer;
},
currentActualState: {
readonly current: IActualResourceLoaderState,
},
) {
// this will set polling even if it was
// already resolved
if (options.pollEvery) {
if (options.pollEvery !== pollIntervalSetAt.current) {
clearInterval(pollInterval.current);
pollInterval.current = setInterval(
doLoad.bind(null, options, ssrContext, token, setState, isUnmounted, pollIntervalSetAt, pollInterval, currentActualState),
options.pollEvery * 1000,
);
pollIntervalSetAt.current = options.pollEvery;
}
} else {
clearInterval(pollInterval.current);
pollIntervalSetAt.current = null;
}
let path = options.path || "/rest/resource/";
if (!path.endsWith("/")) {
path += "/";
}
// now just like we did in the constructor we calculate this form
const actualSrc = path + (options.src[0] === "/" ? options.src.substr(1) : options.src);
const shouldRequestNewAlways = (new Date()).getTime() - LOAD_TIME > SSR_GRACE_TIME;
// we run this request in order to cache, even when it's not really used
const headers: any = {
"sw-cacheable": typeof options.swCacheable ? JSON.stringify(options.swCacheable) : "true",
"sw-network-first": typeof options.swNetworkFirst ? JSON.stringify(options.swNetworkFirst) : "false",
"sw-recheck": typeof options.swRecheck ? JSON.stringify(options.swRecheck) : "false",
};
if (options.includeToken && token) {
headers.token = token;
}
// and this executes regardless we have an html content or not, so we are going to check
// our ssr context again here, and who knows, the user might be going back or doing
// something that we can take advantage of our ssr context once again
if (
ssrContext &&
(ssrContext.resources[actualSrc]) &&
!shouldRequestNewAlways
) {
// so we found it there so we get the value
const contentNew = (ssrContext.resources[actualSrc]);
// but it's only necessary to change the state if it is really
// going to differ in a meaningful way
if (contentNew !== currentActualState.current.content) {
setState({
content: options.type === "json" ? JSON.parse(contentNew) : contentNew,
failed: false,
loading: false,
});
}
if ((options.swCacheable || options.swRecheck) && !options.clientSideResolver) {
// now we are going to call the API anyway
// the reason for this is that we want to tickle the service
// worker to cache this file for the given buildnumber
const loadURL = actualSrc;
fetch(loadURL, {
headers,
credentials: "omit",
});
}
// return
return;
}
// otherwise here we are going to clear everything
// yes we set loading to false, the reason is that
// we might not have a src at all, and we don't want to show
// a loading component for 1 microsecond or how fast the
// interner might be, so for now, we just clear it up
setState({
failed: false,
loading: false,
});
// so now we have this timeout, if the timeout lapses
// before we have fetched then we can say we are loading, so
// no need for flickering
const waitTimeoutForLoading = setTimeout(() => {
if (!isUnmounted.current) {
if (options.keepContentDuringLoading) {
setState({
loading: true,
});
} else {
setState({
content: null,
loading: true,
});
}
}
}, 600);
// now the load url we will use
const loadURL = actualSrc;
// and we try to load
try {
if (options.clientSideResolver) {
const response = await options.clientSideResolver(loadURL);
clearTimeout(waitTimeoutForLoading);
!isUnmounted.current && setState({
content: options.type === "json" ? JSON.parse(response) : response,
loading: false,
failed: false,
});
return;
}
// and here we run fetch
const fetchResponse =
await fetch(loadURL, {
headers,
credentials: "omit",
});
// if we have bad status
if (
fetchResponse.status !== 200 &&
fetchResponse.status !== 0
) {
// throw an error to trigger it into catch clause
throw new Error("Invalid status code");
}
// now we can read our text content
const content = options.type === "text" ? await fetchResponse.text() : (
options.type === "binary-blob" ? await fetchResponse.blob() : (
options.type === "binary-arraybuffer" ? await fetchResponse.arrayBuffer() : await fetchResponse.json()
)
);
// and we can stop the timer for wait for loading
clearTimeout(waitTimeoutForLoading);
// and finally set our state with our new shiny html
// content
!isUnmounted.current && setState({
content,
loading: false,
failed: false,
});
} catch (err) {
// we have failed, clear the timeout, and show it
clearTimeout(waitTimeoutForLoading);
!isUnmounted.current && setState({
content: null,
loading: false,
failed: true,
});
}
}
/**
* The actual html resource loader with access to the SSR context
*/
class ActualResourceLoader
extends React.PureComponent {
private isUnmounted: boolean = false;
private pollIntervalSetAt: number = null;
private pollInterval: NodeJS.Timer = null;
public static async getDerivedServerSideStateFromProps(props: IActualResourceLoaderProps) {
let path = props.path || "/rest/resource/";
if (!path.endsWith("/")) {
path += "/";
}
const actualSrc = path + (props.src[0] === "/" ? props.src.substr(1) : props.src);
// cannot resolve in the server side
if (!actualSrc.startsWith("/rest/resource/") && !props.serverSideResolver) {
return null;
}
const value = await props.root.callRequestManagerResource(actualSrc, props.serverSideResolver);
return {
content: props.type === "json" ? JSON.parse(value) : value,
loading: false,
failed: false,
}
}
constructor(props: IActualResourceLoaderProps) {
super(props);
// and set the state for this
this.state = {
content: construct(props, props.ssrContext, props.root),
loading: false,
failed: false,
};
this.load = this.load.bind(this);
}
public componentWillUnmount() {
this.isUnmounted = true;
}
/**
* Performs the loading of a html resource
*/
public load() {
// clear any potential previous polled
clearInterval(this.pollInterval);
const $this = this;
doLoad(this.props, this.props.ssrContext, this.props.token, this.setState.bind(this), {
get current() {
return $this.isUnmounted;
},
}, {
get current() {
return $this.pollIntervalSetAt;
},
set current(v) {
$this.pollIntervalSetAt = v;
}
}, {
get current() {
return $this.pollInterval;
},
set current(v) {
$this.pollInterval = v;
}
}, {
get current() {
return $this.state;
}
});
}
public componentDidMount() {
// on mount we run the load
this.load();
}
public componentDidUpdate(prevProps: IActualResourceLoaderProps) {
// run the load function if the src changes
if (
prevProps.src !== this.props.src ||
prevProps.path !== this.props.path ||
prevProps.id !== this.props.id
) {
this.load();
}
}
public render() {
return this.props.children(this.state.content, this.state.loading, this.state.failed);
}
}
/**
* Displays a html resource that is included within the resources that this application
* is shipped with
* @param props the resource loader props
* @returns an react component that wraps this html content
*/
export default function ResourceLoader(props: IResourceLoaderProps) {
const root = useRootRetriever();
const ssrContext = useContext(SSRContext);
const tokenInfo = useContext(TokenContext);
return (
);
}
export function useResourceLoader(options: IResourceLoaderOptions) {
// we will do this a bit differently from the item which is more precise
const tokenInfo = useContext(TokenContext);
const root = useRootRetriever();
const ssrContext = useContext(SSRContext);
if (root.root.hasRequestManager()) {
let path = options.path || "/rest/resource/";
if (!path.endsWith("/")) {
path += "/";
}
const actualSrc = path + (options.src[0] === "/" ? options.src.substr(1) : options.src);
// cannot resolve in the server side
if (!actualSrc.startsWith("/rest/resource/") && !options.serverSideResolver) {
} else if (root.root.needsRequestManagerResource(actualSrc)) {
throw new SSRError(async () => {
await root.root.callRequestManagerResource(actualSrc, options.serverSideResolver);
});
}
}
// make our base state and the set state emulation
const [stateBase, setStateBase] = useState(() => ({
content: construct(options, ssrContext, root.root),
loading: false,
failed: false,
}));
const alwaysUpToDateStateRef = useRef(stateBase);
alwaysUpToDateStateRef.current = stateBase;
const setState = useCallback((newState: Partial) => {
const newValue = {
...alwaysUpToDateStateRef.current,
...newState,
};
alwaysUpToDateStateRef.current = newValue;
setStateBase(newValue);
}, []);
// set our private internal variables
const pollIntervalSetAtRef = useRef(null);
const pollIntervalRef = useRef(null);
// we use this to cheap emulate component did update
// but without it, just check that what we last loaded
// is different, by default we have last loaded
// our current, however the load function gets called on
// mount regardless of state because load always gets called
const loadedPath = useRef(options.path);
const loadedSrc = useRef(options.src);
const loadedId = useRef(options.id);
// this is our load function
const load = useCallback(() => {
loadedPath.current = options.path;
loadedSrc.current = options.src;
// clear any potential previous polled
clearInterval(pollIntervalRef.current);
// load from new
doLoad(options, ssrContext, tokenInfo.token, setState, isUnmountedRef, pollIntervalSetAtRef, pollIntervalRef, alwaysUpToDateStateRef);
}, [options, ssrContext, tokenInfo.token]);
// now here we set the unmounted ref and call load regardless
// because we will always try to load, eg. to set poll every
// and whatnot even if the content was loaded by SSR
const isUnmountedRef = useRef(false);
useEffect(() => {
load();
return () => {
isUnmountedRef.current = true;
}
}, []);
// load from new
useEffect(() => {
// only load if the path or the src have changed
// other options will have no effect
if (
loadedPath.current !== options.path ||
loadedSrc.current !== options.src ||
loadedId.current !== options.id
) {
load();
}
}, [options.src, options.path, options.id, load]);
const memoValue = useMemo(() => {
return (
{
value: stateBase.content,
loading: stateBase.loading,
failed: stateBase.failed,
}
);
}, [
stateBase.content,
stateBase.loading,
stateBase.failed,
])
return memoValue;
}