react-sweet-state
Version:
Global + local state combining the best of Redux and Context API
227 lines (191 loc) • 8.74 kB
JavaScript
const _excluded = ["children"],
_excluded2 = ["scope", "isGlobal"];
function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
import React, { useCallback, useContext, useMemo, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Context } from '../context';
import { StoreRegistry, bindActions } from '../store';
import shallowEqual from '../utils/shallow-equal';
const noop = () => () => {};
export function createContainer(StoreOrOptions, _temp) {
if (StoreOrOptions === void 0) {
StoreOrOptions = {};
}
let {
onInit: _onInit = noop,
onUpdate = noop,
onCleanup = noop,
displayName = ''
} = _temp === void 0 ? {} : _temp;
if ('key' in StoreOrOptions) {
const Store = StoreOrOptions;
const dn = displayName || `Container(${Store.key.split('__')[0]})`;
return createFunctionContainer({
displayName: dn,
// compat fields
override: {
Store,
handlers: Object.assign({}, _onInit !== noop && {
onInit: () => _onInit()
}, onCleanup !== noop && {
onDestroy: () => onCleanup()
}, // TODO: on next major pass through next/prev props args
onUpdate !== noop && {
onContainerUpdate: () => onUpdate()
})
}
});
}
return createFunctionContainer(StoreOrOptions);
}
function useRegistry(scope, isGlobal, _ref) {
let {
globalRegistry
} = _ref;
return useMemo(() => {
const isLocal = !scope && !isGlobal;
return isLocal ? new StoreRegistry('__local__') : globalRegistry;
}, [scope, isGlobal, globalRegistry]);
}
function useContainedStore(scope, registry, propsRef, check, override) {
// Store contained scopes in a map, but throwing it away on scope change
// eslint-disable-next-line react-hooks/exhaustive-deps
const containedStores = useMemo(() => new Map(), [scope]);
const getContainedStore = useCallback(Store => {
let containedStore = containedStores.get(Store); // first time it gets called we add store to contained map bound
// so we can provide props to actions (only triggered by children)
if (!containedStore) {
const isExisting = registry.hasStore(Store, scope);
const config = {
props: () => propsRef.current.sub,
contained: check
};
const {
storeState
} = registry.getStore(Store, scope, config);
const actions = bindActions(Store.actions, storeState, config);
const handlers = bindActions(Object.assign({}, Store.handlers, override == null ? void 0 : override.handlers), storeState, config, actions);
containedStore = {
storeState,
actions,
handlers,
unsubscribe: undefined
};
containedStores.set(Store, containedStore); // Signal store is contained and ready now, so by the time
// consumers subscribe we already have updated the store (if needed).
// Also if override maintain legacy behaviour, triggered on every mount
if (!isExisting || override) handlers.onInit == null ? void 0 : handlers.onInit();
}
return containedStore;
}, [containedStores, scope, registry, propsRef, check, override]);
return [containedStores, getContainedStore];
}
function useApi(check, getContainedStore, _ref2) {
let {
globalRegistry,
retrieveStore
} = _ref2;
const retrieveRef = useRef();
retrieveRef.current = Store => check(Store) ? getContainedStore(Store) : retrieveStore(Store); // This api is "frozen", as changing it will trigger re-render across all consumers
// so we link retrieveStore dynamically and manually call notify() on scope change
return useMemo(() => ({
globalRegistry,
retrieveStore: s => retrieveRef.current(s)
}), [globalRegistry]);
}
function createFunctionContainer(_temp2) {
let {
displayName,
override
} = _temp2 === void 0 ? {} : _temp2;
const check = store => override ? store === override.Store : store.containedBy === FunctionContainer;
function FunctionContainer(props) {
const {
children
} = props,
restProps = _objectWithoutPropertiesLoose(props, _excluded);
const {
scope,
isGlobal
} = restProps,
subProps = _objectWithoutPropertiesLoose(restProps, _excluded2);
const ctx = useContext(Context);
const registry = useRegistry(scope, isGlobal, ctx); // Store props in a ref to avoid re-binding actions when they change and re-rendering all
// consumers unnecessarily. The update is handled by an effect on the component instead
const propsRef = useRef({
prev: null,
next: restProps,
sub: subProps
});
propsRef.current = {
prev: propsRef.current.next,
next: restProps,
sub: subProps // TODO remove on next major
};
const [containedStores, getContainedStore] = useContainedStore(scope, registry, propsRef, check, override); // Use a stable object as is passed as value to context Provider
const api = useApi(check, getContainedStore, ctx); // This listens for custom props change, and so we trigger container update actions
// before the re-render gets to consumers (hence why side effect on render).
// We do not use React hooks because num of restProps might change and react will throws
if (!shallowEqual(propsRef.current.next, propsRef.current.prev)) {
containedStores.forEach(_ref3 => {
let {
handlers
} = _ref3;
handlers.onContainerUpdate == null ? void 0 : handlers.onContainerUpdate(propsRef.current.next, propsRef.current.prev);
});
} // Every time we add/remove a contained store, we ensure we are subscribed to the updates
// as an effect to properly handle strict mode
useEffect(() => {
containedStores.forEach(containedStore => {
if (!containedStore.unsubscribe) {
const unsub = containedStore.storeState.subscribe(() => containedStore.handlers.onUpdate == null ? void 0 : containedStore.handlers.onUpdate());
containedStore.unsubscribe = () => {
unsub();
containedStore.unsubscribe = undefined;
};
}
});
}, [containedStores, containedStores.size]); // We support renderding "bootstrap" containers without children with override API
// so in this case we call getCS to initialize the store globally asap
if (override && !containedStores.size && (scope || isGlobal)) {
getContainedStore(override.Store);
} // This listens for scope change or component unmount, to notify all consumers
// so all work is done on cleanup
useEffect(() => {
return () => {
containedStores.forEach((_ref4, Store) => {
let {
storeState,
handlers,
unsubscribe
} = _ref4;
// Detatch container from subscription
unsubscribe == null ? void 0 : unsubscribe(); // Trigger a forced update on all subscribers as we opted out from context
// Some might have already re-rendered naturally, but we "force update" all anyway.
// This is sub-optimal as if there are other containers with the same
// old scope id we will re-render those too, but still better than context
storeState.notify(); // Given unsubscription is handled by useSyncExternalStore, we have no control on when
// React decides to do it. So we schedule on next tick to run last
Promise.resolve().then(() => {
var _registry$getStore;
if (!storeState.listeners().size && // ensure registry has not already created a new store with same scope
storeState === ((_registry$getStore = registry.getStore(Store, scope, null)) == null ? void 0 : _registry$getStore.storeState)) {
handlers.onDestroy == null ? void 0 : handlers.onDestroy(); // We only delete scoped stores, as global shall persist and local are auto-deleted
if (!isGlobal) registry.deleteStore(Store, scope);
}
});
}); // no need to reset containedStores as the map is already bound to scope
};
}, [registry, scope, isGlobal, containedStores]);
return /*#__PURE__*/React.createElement(Context.Provider, {
value: api
}, children);
}
FunctionContainer.displayName = displayName || `Container`;
FunctionContainer.propTypes = {
children: PropTypes.node,
scope: PropTypes.string,
isGlobal: PropTypes.bool
};
return FunctionContainer;
}