react-jsbox
Version:
A custom React renderer for writing JSBox apps in React.
473 lines (373 loc) • 11.8 kB
TypeScript
import ReactFiberReconciler from 'react-reconciler';
import { useState, useEffect, useRef, useCallback, useMemo, useReducer, useLayoutEffect } from 'react';
const hasOwnProperty = Object.prototype.hasOwnProperty;
// HighRes but slower then Date.now during invoke
// export const now = () => $objc('NSDate').invoke('date').invoke('timeIntervalSince1970') * 1000
const { now } = Date;
const is = {
obj: a => a === Object(a),
str: a => typeof a === 'string',
num: a => typeof a === 'number',
und: a => a === void 0,
arr: a => Array.isArray(a),
equ(a, b) {
// Wrong type, doesn't match
if (typeof a !== typeof b) return false
// Atomic, just compare a against b
if (is.str(a) || is.num(a) || is.obj(a)) return a === b
// Array, shallow compare first to see if it's a match
if (is.arr(a) && a == b) return true
// Last resort, go through keys
let i;
for (i in a) if (!(i in b)) return false
for (i in b) if (a[i] !== b[i]) return false
return is.und(i) ? a === b : true
}
};
function filterProps(oldProps = {}, newProps) {
const sameProps = Object.keys(newProps).filter(key => is.equ(newProps[key], oldProps[key]));
const leftOvers = Object.keys(oldProps).filter(key => newProps[key] === void 0);
const filteredProps = [...sameProps, 'events', 'children', 'key', 'ref'].reduce((acc, prop) => {
let { [prop]: _, ...rest } = acc;
return rest
}, newProps);
leftOvers.forEach(key => key !== 'children' && (filteredProps[key] = undefined));
return filteredProps
}
class View {
constructor(type, props) {
const { layout, events, animate } = props;
this._element = $ui.create({
type,
props,
events
});
this._layout = layout;
this._animate = animate;
}
_element = null
_layout = null
_animate = null
get element() {
return this._element
}
applyLayout() {
if (typeof this._layout === 'function') {
this.element.layout(this._layout);
}
}
updateLayout() {
if (typeof this._layout === 'function') {
this.element.updateLayout(this._layout);
}
}
remakeLayout() {
if (typeof this._layout === 'function') {
this.element.remakeLayout(this._layout);
}
}
appendChild(child) {
this.element.add(child.element);
child.applyLayout();
}
removeChild(child) {
child.element.remove();
}
insertBefore(child, beforeChild) {
this.element.insertBelow(child.element, beforeChild.element);
child.applyLayout();
}
update(updatePayload) {
let needsUpdateLayout = false;
if (hasOwnProperty.call(updatePayload, 'layout')) {
this._layout = updatePayload.layout;
needsUpdateLayout = true;
delete updatePayload.layout;
}
if (hasOwnProperty.call(updatePayload, 'animate')) {
this._animate = updatePayload.animate;
delete updatePayload.animate;
}
const element = this.element;
if (this._animate) {
const { duration = 0.4, damping = 0, velocity = 0, options = 0, completion = () => {} } = this._animate;
$ui.animate({
duration,
animation() {
Object.keys(updatePayload).forEach(prop => {
element[prop] = updatePayload[prop];
});
},
damping,
velocity,
options,
completion
});
this.showOverlay();
return
}
Object.keys(updatePayload).forEach(prop => {
element[prop] = updatePayload[prop];
});
needsUpdateLayout && this.updateLayout();
this.showOverlay();
}
showOverlay() {
if (!global.__REACT_JSBOX_HIGHLIGHT_UPDATES__) {
return
}
const { cornerRadius, smoothCorners, size } = this.element;
const overlayView = $ui.create({
type: 'view',
props: {
frame: $rect(0, 0, size.width, size.height),
alpha: 0.6,
cornerRadius,
smoothCorners,
bgcolor: $color('clear'),
borderColor: $color('#37afa9'),
borderWidth: 2,
userInteractionEnabled: false
}
});
this.element.add(overlayView);
setTimeout(() => {
overlayView.remove();
}, 300);
}
}
const NO_CONTEXT = true;
const hostConfig = {
now,
setTimeout,
clearTimeout,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
getPublicInstance({ element }) {
return element
},
getRootHostContext() {
return NO_CONTEXT
},
getChildHostContext() {
return NO_CONTEXT
},
prepareForCommit() {
// noop
},
resetAfterCommit() {
// noop
},
createInstance(type, props, internalInstanceHandle) {
return new View(type, props)
},
appendInitialChild(parentInstance, child) {
parentInstance.appendChild(child);
child.applyLayout();
},
finalizeInitialChildren(parentInstance, type, props) {
return false
},
prepareUpdate(instance, type, oldProps, newProps) {
return filterProps(oldProps, newProps)
},
shouldSetTextContent() {
return false
},
shouldDeprioritizeSubtree(type, props) {
return !!props.hidden
},
createTextInstance() {
return null
},
appendChild(parentInstance, child) {
parentInstance.appendChild(child);
},
appendChildToContainer(parentInstance, child) {
const parent = parentInstance.element || parentInstance;
parent.add(child.element);
child.applyLayout();
},
commitMount(instance, updatePayload, type, oldProps, newProps) {
// noop
},
commitUpdate(instance, updatePayload, type, oldProps, newProps) {
if (updatePayload) {
instance.update(updatePayload);
}
},
insertBefore(parentInstance, child, beforeChild) {
parentInstance.insertBefore(child, beforeChild);
},
insertInContainerBefore(parentInstance, child, beforeChild) {
const parent = parentInstance.element || parentInstance;
parent.insertBelow(child.element, beforeChild.element);
},
removeChild(parentInstance, child) {
parentInstance.removeChild(child);
},
removeChildFromContainer(parentInstance, child) {
child.element.remove();
},
resetTextContent() {
// noop
},
hideInstance(instance) {
instance.element.hidden = true;
},
unhideInstance(instance) {
instance.element.hidden = false;
},
hideTextInstance(instance) {
// noop
},
unhideTextInstance(instance, props) {
// noop
},
getFundamentalComponentInstance(fundamentalInstance) {
throw new Error('Not yet implemented.')
},
mountFundamentalComponent(fundamentalInstance) {
throw new Error('Not yet implemented.')
},
shouldUpdateFundamentalComponent(fundamentalInstance) {
console.warn('Not yet implemented.');
return false
},
updateFundamentalComponent(fundamentalInstance) {
throw new Error('Not yet implemented.')
},
unmountFundamentalComponent(fundamentalInstance) {
throw new Error('Not yet implemented.')
},
cloneFundamentalInstance(fundamentalInstance) {
throw new Error('Not yet implemented.')
},
clearContainer(container) {
container?.views?.forEach(view => view?.remove());
},
getInstanceFromNode() {
throw new Error('Not yet implemented.')
},
beforeActiveInstanceBlur() {
// noop
},
afterActiveInstanceBlur() {
// noop
},
preparePortalMount() {
// noop
},
prepareScopeUpdate() {},
getInstanceFromScope() {
throw new Error('Not yet implemented.')
}
};
const reconciler = ReactFiberReconciler(hostConfig);
const isConcurrent = true;
const hydrate = false;
const defaultOptions = {
onInit: () => {},
onRender: () => {}
};
function render(element, container, options) {
const rendererOptions = Object.assign({}, defaultOptions, options);
let fiberRoot = container._reactRootContainer;
if (!fiberRoot) {
container.views.forEach(view => view.remove());
const newFiberRoot = reconciler.createContainer(container, isConcurrent, hydrate);
// eslint-disable-next-line
fiberRoot = container._reactRootContainer = newFiberRoot;
}
rendererOptions.onInit(reconciler);
return reconciler.updateContainer(element, fiberRoot, null, rendererOptions.onRender)
}
const useCache = (key, initialValue) => {
const [state, setState] = useState(() => {
const cacheValue = $cache.get(key);
if (cacheValue === undefined) {
$cache.set(key, initialValue);
return initialValue
}
return cacheValue
});
useEffect(() => void $cache.set(key, state));
return [state, setState]
};
function useTimeoutFn(fn, ms = 0) {
const ready = useRef(false);
const timeout = useRef();
const callback = useRef(fn);
const isReady = useCallback(() => ready.current, []);
const set = useCallback(() => {
ready.current = false;
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
ready.current = true;
callback.current();
}, ms);
}, [ms]);
const clear = useCallback(() => {
ready.current = null;
timeout.current && clearTimeout(timeout.current);
}, []);
// update ref when function changes
useEffect(() => {
callback.current = fn;
}, [fn]);
// set on mount, clear on unmount
useEffect(() => {
set();
return clear
}, [ms]);
return [isReady, clear, set]
}
function useDebounce(fn, ms = 0, deps = []) {
const [isReady, cancel, reset] = useTimeoutFn(fn, ms);
useEffect(reset, deps);
return [isReady, cancel]
}
const useEventHandler = (eventHandlerMap, deps) => useMemo(() => eventHandlerMap, deps);
const useFirstMountState = () => {
const isFirst = useRef(true);
if (isFirst.current) {
isFirst.current = false;
return true
}
return isFirst.current
};
const useLatest = value => {
const ref = useRef(value);
ref.current = value;
return ref
};
function useRendersCount() {
return ++useRef(0).current
}
const updateReducer = num => (num + 1) % 1000000;
const useUpdate = () => {
const [, update] = useReducer(updateReducer, 0);
return update
};
const useUpdateEffect = (effect, deps) => {
const isFirstMount = useFirstMountState();
useEffect(() => {
if (!isFirstMount) {
return effect()
}
}, deps);
};
const useUpdateLayoutEffect = (effect, deps) => {
const isFirstMount = useFirstMountState();
useLayoutEffect(() => {
if (!isFirstMount) {
return effect()
}
}, deps);
};
export { render, useCache, useDebounce, useEventHandler, useFirstMountState, useLatest, useRendersCount, useTimeoutFn, useUpdate, useUpdateEffect, useUpdateLayoutEffect };