UNPKG

react-async-states

Version:

A low-level multi paradigm state management library

1,122 lines (1,106 loc) 55.8 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('async-states')) : typeof define === 'function' && define.amd ? define(['exports', 'react', 'async-states'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ReactAsyncStates = {}, global.React, global.AsyncStates)); })(this, (function (exports, React, asyncStates) { 'use strict'; function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React); const __DEV__ = process.env.NODE_ENV !== "production"; function isFunction(fn) { return typeof fn === "function"; } const emptyArray = []; let assign = Object.assign; let freeze = Object.freeze; let isArray = Array.isArray; function computeCallerName(level = 3) { const stack = new Error().stack?.toString(); if (!stack) { return undefined; } const regex = new RegExp(/at.(\w+).*$/, "gm"); let levelsCount = 0; let match = regex.exec(stack); while (levelsCount < level && match) { match = regex.exec(stack); levelsCount += 1; } return match?.[1]; } function useCallerName(level) { if (!__DEV__) { return undefined; } // Here, we are using useMemo like a useRef. // It is safe to read/write to it here during render, because it is a single // time computed information that will never change. // Why not using useMemo(() => computeCallerName(level), [level]) // is because in this case we would have to deal with the call level from // inside React, which is unstable and isn't linked to the project. // To avoid that entirely, we force the computation of the caller here // in this hook so it won't leak further into React internals. // Using useMemo because it is lightweight. // Avoiding useRef so we won't have any warnings issues from a future // StrictMode or compiler thing. let ref = React__namespace.useMemo(() => ({}), [level]); if (!ref.current) { ref.current = computeCallerName(level); } return ref.current; } let Context = React__namespace.createContext(null); let maybeWindow = typeof window !== "undefined" ? window : undefined; let isServer = typeof maybeWindow === "undefined" || "Deno" in maybeWindow; let currentOverrides; function setCurrentHookOverrides(overrides) { currentOverrides = overrides; } function cloneSourceInTheServer(globalSource, context, useGlobalSourceState) { let instance = globalSource.inst; let { config, fn, key } = instance; let newConfig = assign({}, config, { context }); let source = asyncStates.createSource(key, fn, newConfig); let newInstance = source.inst; if (useGlobalSourceState === true) { let globalInstance = globalSource.inst; // we will clone all relevant things: state, cache, lastRun newInstance.state = globalInstance.state; newInstance.cache = globalInstance.cache; newInstance.latestRun = globalInstance.latestRun; newInstance.lastSuccess = globalInstance.lastSuccess; } newInstance.global = true; return source; } // the goal of this function is to retrieve the following objects: // - a configuration object to use { key, producer, source, lazy ... } // - the state instance function parseConfig(currentLibContext, options, overrides) { requireAnExecContextInServer(currentLibContext, options); let executionContext; let instance; let parsedConfiguration; switch (typeof options) { // the user provided an object configuration or a Source // In the case of a source, we will detect if it was a global source // (the heuristic to detect that is to check if it belongs to another // context object), if that's the case, a new source will be created // in the current context and used case "object": { if (asyncStates.isSource(options)) { if (isServer) { // requireAnExecContextInServer would throw if nullish let ctx = currentLibContext.ctx; instance = cloneSourceInTheServer(options, ctx).inst; } else { instance = options.inst; } parsedConfiguration = assign({}, overrides, currentOverrides); parsedConfiguration.source = options; break; } let config = options; if (config.source && asyncStates.isSource(config.source)) { let baseSource = config.source; if (isServer) { // requireAnExecContextInServer would throw if nullish let ctx = currentLibContext.ctx; let useServerState = config.useServerState; baseSource = cloneSourceInTheServer(baseSource, ctx, useServerState); } let realSource = baseSource.getLane(config.lane); instance = realSource.inst; parsedConfiguration = assign({}, config, overrides, currentOverrides); parsedConfiguration.source = realSource; break; } let nullableExecContext = currentLibContext; if (config.context) { executionContext = asyncStates.createContext(config.context); } else if (nullableExecContext) { executionContext = nullableExecContext; } else { executionContext = asyncStates.requestContext(null); } parsedConfiguration = assign({}, options, overrides, currentOverrides); // parsedConfig is created by the library, so okay to mutate it internally parsedConfiguration.context = executionContext.ctx; if (!executionContext) { throw new Error("Exec context not defined, this is a bug"); } instance = resolveFromObjectConfig(executionContext, parsedConfiguration); break; } // this is a string provided to useAsync, that we will lookup the instance // by the given key in the context, if not found it will be created // with the given config and it will stay there case "string": { parsedConfiguration = assign({}, overrides, currentOverrides); parsedConfiguration.key = options; let nullableExecContext = currentLibContext; if (nullableExecContext) { executionContext = nullableExecContext; } else { executionContext = asyncStates.requestContext(null); } // parsedConfig is created by the library, so okay to mutate it internally parsedConfiguration.context = executionContext.ctx; instance = resolveFromStringConfig(executionContext, parsedConfiguration); break; } // this is a function provided to useAsync, means the state instance // will be removed when the component unmounts and it won't be stored in the // context case "function": { parsedConfiguration = assign({}, overrides, currentOverrides); parsedConfiguration.producer = options; parsedConfiguration.context = currentLibContext?.ctx ?? null; instance = resolveFromFunctionConfig(parsedConfiguration); break; } // at this point, config is a plain object default: { parsedConfiguration = assign({}, overrides, currentOverrides); let nullableExecContext = currentLibContext; if (nullableExecContext) { executionContext = nullableExecContext; } else { executionContext = asyncStates.requestContext(null); } // the parsed config is created by the library, so okay to mutate it. parsedConfiguration.context = executionContext.ctx; instance = resolveFromObjectConfig(executionContext, parsedConfiguration); } } return { instance, config: parsedConfiguration, }; } // object type has these specific rules: // - it is not a source // - the user provided a configuration object (not through overrides) // - cases when it contains { source } should be supported before calling this function resolveFromObjectConfig(executionContext, parsedConfiguration) { let { key, producer } = parsedConfiguration; if (!key) { requireAKeyInTheServer(); key = asyncStates.nextKey(); // anonymous states won't be stored in the context for easier GC parsedConfiguration.storeInContext = false; } let existingInstance = executionContext.get(key); if (existingInstance) { return existingInstance.actions.getLane(parsedConfiguration.lane).inst; } return asyncStates.createSource(key, producer, parsedConfiguration).getLane(parsedConfiguration.lane).inst; } // the user provided a string to useAsync(key, deps) function resolveFromStringConfig(executionContext, parsedConfiguration) { // key should never be undefined in this path let key = parsedConfiguration.key; let existingInstance = executionContext.get(key); if (existingInstance) { return existingInstance; } return asyncStates.createSource(key, null, parsedConfiguration).inst; } function resolveFromFunctionConfig(parsedConfiguration) { requireAKeyInTheServer(); let key = asyncStates.nextKey(); // anonymous states won't be stored in the context for easier GC parsedConfiguration.storeInContext = false; // todo: reuse instance from previous render return asyncStates.createSource(key, parsedConfiguration.producer, parsedConfiguration) .inst; } // this function throws in the server when there is no context provided function requireAnExecContextInServer(parentExecContext, mixedConfig) { // opt-out for these cases: // - not in server // - we are in a Library Context provider tree (and not using a source) // - the provided config is not an object (then, we will attach to parent provider) if (!isServer || typeof mixedConfig !== "object") { return; } if (parentExecContext) { return; } let baseConfig = mixedConfig; // at this point, we have an object (not a source) if (!baseConfig.context) { if (__DEV__) { console.error("A context object is mandatory when working in the server " + "to avoid leaks between requests. \nAdd the following up in the tree:\n" + "import { Provider } from 'react-async-states';\n" + "<Provider>{yourChildrenTree}</Provider>;\n"); } throw new Error("A Provider is mandatory in the server"); } } function requireAKeyInTheServer() { if (!isServer) { return; } throw new Error("A key is required in the server"); } let idPrefix = "$$as-"; function Provider({ exclude, children, context: contextArg, serverInsertedHtmlHook, }) { // automatically reuse parent context when there is and no 'context' object // is provided let reactId = React__namespace.useId(); let parentLibraryProvider = React__namespace.useContext(Context); let libraryContextObject = React__namespace.useMemo(() => { if (!contextArg && contextArg !== null) { if (parentLibraryProvider) { return parentLibraryProvider; } let libraryContext = asyncStates.createContext({}); libraryContext.name = `__$$${reactId}`; return libraryContext; } let libraryContext = asyncStates.createContext(contextArg); if (contextArg !== null) { libraryContext.name = `__$$${reactId}`; } else if (isServer) { throw new Error("Global context cannot be used server"); } return libraryContext; }, [reactId, contextArg]); if (isFunction(serverInsertedHtmlHook)) { serverInsertedHtmlHook(() => (React__namespace.createElement(HydrateRemainingContextInstances, { exclude: exclude, context: libraryContextObject }))); } React__namespace.useEffect(() => ensureOnDemandHydrationExistsInClient(libraryContextObject), [libraryContextObject]); // memoized children will unlock the React context children optimization: // if the children reference is the same as the previous render, it will // bail out and skip the children render and only propagates the context // change. return (React__namespace.createElement(Context.Provider, { value: libraryContextObject }, children)); } function ensureOnDemandHydrationExistsInClient(libraryContextObject) { let ctxName = libraryContextObject.name; let hydrationDataPropName = !ctxName ? "__$$" : ctxName; let rehydrationFunctionName = !ctxName ? "__$$_H" : `${ctxName}_H`; window[rehydrationFunctionName] = function rehydrateContext() { let hydrationData = window[hydrationDataPropName]; if (!hydrationData || !libraryContextObject) { return; } Object.entries(hydrationData).forEach(([key, instanceHydration]) => { let instance = libraryContextObject.get(key); if (!instance || !instanceHydration) { return; } let [state, latestRun, payload] = instanceHydration; instance.version += 1; instance.state = state; let promise = instance.promise; // next, we may have already hydrated the "pending" state, in this case // we put a never resolving promise and it probably did suspend a tree // in this case, we will resolve/reject it imperatively because // we keep track of this value. // setting state won't resolve because if this is the first ever component // render and mount, it won't run any effects and thus no subscribers. // so, the only way is to inform react that the suspending promise did // fulfill, via its resolve and reject functions. if (state.status === "success") { instance.lastSuccess = state; if (promise) { promise.value = state.data; promise.status = "fulfilled"; } instance.res?.res(state.data); } else if (state.status === "error" && promise) { promise.status = "rejected"; promise.reason = state.data; instance.res?.rej(state.data); } instance.payload = payload; instance.latestRun = latestRun; let subscriptions = instance.subscriptions; if (subscriptions) { Object.values(subscriptions).forEach((sub) => sub.props.cb(state)); } delete hydrationData[key]; }); }; } function HydrateRemainingContextInstances({ context, exclude, }) { let sources = context .getAll() .filter((instance) => { // this means we already hydrated this instance in this context with this // exact same version if (context.payload[instance.key] === instance.version) { return false; } if (isFunction(exclude)) { return !exclude(instance.key, instance.state); } return true; }) .map((t) => t.actions); return (React__namespace.createElement(HydrationServer, { useReactId: false, context: context, target: sources })); } function HydrationComponent({ target }) { let context = React__namespace.useContext(Context); if (isServer) { return React__namespace.createElement(HydrationServer, { context: context, target: target }); } return React__namespace.createElement(HydrationClient, { target: target }); } function HydrationServer({ context, target, useReactId, }) { let reactId = React__namespace.useId(); let hydrationData = buildWindowAssignment(target, context); if (!hydrationData) { return null; } let id = useReactId === false ? undefined : `${idPrefix}${reactId}`; return (React__namespace.createElement("script", { id: id, dangerouslySetInnerHTML: { __html: hydrationData } })); } function HydrationClient(_props) { let reactId = React__namespace.useId(); let id = `${idPrefix}${reactId}`; let existingHtml = React__namespace.useMemo(() => ({ current: { html: null, init: false } }), []); // We are using the "init" property explicitly to be more precise: // If we didn't compute, let's do it, or else, just pass. // In the or else path, it may be difficult to distinguish between falsy // values and we would end up using two values anyways. So, we better use // an object to be more explicit and readable. // For example, we could do: // if (existingHTML.current === null) ... // But there is no guarantee that the innerHTML computation will always yield // non null values. To avoid all of that, let's stick to basic javascript. if (!existingHtml.current.init) { let container = document.getElementById(id); let containerInnerHTML = container?.innerHTML ?? null; existingHtml.current = { init: true, html: containerInnerHTML }; } let __html = existingHtml.current.html; if (__html) { return React__namespace.createElement("script", { id: id, dangerouslySetInnerHTML: { __html } }); } return null; } function buildWindowAssignment(sources, context) { if (!sources.length) { return null; } let globalHydrationData = null; let contextHydrationData = null; for (let source of sources) { let key = source.key; let instance = context.get(key); if (!instance) { if (__DEV__) { __DEV__warnInDevAboutHydratingSourceNotInContext(key); } throw new Error("Cannot leak server global source"); } let { state, latestRun, payload, version } = instance; if (context.payload[key] === version) { continue; } else { context.payload[key] = version; } // instance.global is true only and only if this instance was cloned // from a server instance: // ie: You have a global source object in the server, that you clone per // request. When we perform this clone, we mark these sources as global: // It was cloned from a globally accessible source. // We do all of this because when hydrating, there are two types of states: // Those we were global (not related to this render, but more of was // created far away and a subscription is performed from this render), // and those we are bound to the current Context in this particular render. // When hydrating we distinguish between them so we won't leak source state // and we properly assign the state to its instance. // The script will later use __$$ for global context, and <context.name> // for more granular contexts. // When using the server, using a context is mandatory, but if your app // is all global sources, then contextHydrationData will be basically empty // and your global sources in the client will get hydrated correctly. if (instance.global) { if (!globalHydrationData) { globalHydrationData = {}; } globalHydrationData[key] = [state, latestRun, payload]; } else { if (!contextHydrationData) { contextHydrationData = {}; } contextHydrationData[key] = [state, latestRun, payload]; } } if (!globalHydrationData && !contextHydrationData) { return null; } let hydrationData = ["var win=window;"]; if (globalHydrationData) { let globalHydrationDataAsString = JSON.stringify(globalHydrationData); hydrationData.push(buildHydrationScriptContent("__$$", globalHydrationDataAsString)); } if (contextHydrationData) { let contextName = context.name; if (!contextName) { throw new Error("Hydrating context without name, this is a bug"); } let contextHydrationDataAsString = JSON.stringify(contextHydrationData); hydrationData.push(buildHydrationScriptContent(contextName, contextHydrationDataAsString)); } return hydrationData.join(""); } function buildHydrationScriptContent(propName, data) { return `win["${propName}"]=Object.assign(win["${propName}"]||{},${data});win["${propName}_H"]&&win["${propName}_H"]();`; } function __DEV__warnInDevAboutHydratingSourceNotInContext(key) { if (__DEV__) { console.error(`[async-states] source '${key}' doesn't exist` + " in the context, this means that you tried to hydrate it " + " before using it via hooks. Only hydrate a source after using it" + " to avoid leaks and passing unused things to the client."); } } function createLegacyReturn(subscription, config) { return createBaseReturn(subscription, config); } function selectState(instance, selector) { let { state: currentState, lastSuccess, cache } = instance; if (selector) { return selector(currentState, lastSuccess, cache); } else { return currentState; } } function createBaseReturn(subscription, config) { let instance = subscription.instance; let currentState = instance.state; let lastSuccess = instance.lastSuccess; let selectedState = selectState(instance, config.selector); let previousState = currentState.status === "pending" ? currentState.prev : currentState; let status = currentState.status; let source = subscription.instance.actions; return freeze({ source, state: selectedState, dataProps: lastSuccess.props, isError: status === "error", isInitial: status === "initial", isPending: status === "pending", isSuccess: status === "success", data: lastSuccess.data ?? null, error: previousState.status === "error" ? previousState.data : null, onChange: subscription.onChange, onSubscribe: subscription.onSubscribe, read: subscription.read.bind(null, config), Hydrate: () => React__namespace.createElement(HydrationComponent, { target: [source] }), }); } function selectWholeState(state) { return state; } let isCurrentlyRunningOnRender = false; function startRenderPhaseRun() { let prev = isCurrentlyRunningOnRender; isCurrentlyRunningOnRender = true; return prev; } function endRenderPhaseRun(nextValue) { isCurrentlyRunningOnRender = nextValue; } function isRenderPhaseRun() { return isCurrentlyRunningOnRender; } function shouldRunSubscription(subscription, subscriptionConfig) { let { instance, alternate } = subscription; let config = alternate?.config || subscriptionConfig; let { lazy, condition, autoRunArgs } = config; return shouldSubscriptionRun(instance.state, instance.actions.getPayload(), lazy, condition, (autoRunArgs || emptyArray)); } function shouldSubscriptionRun(state, payload, lazy, condition, args) { if (lazy === false) { if (condition === undefined || condition === true) { return true; } else if (isFunction(condition)) { return condition(state, args, payload); } } return false; } function reconcileInstance(instance, currentConfig) { let instanceActions = instance.actions; // 📝 We can call this part the instance reconciliation // patch the given config and the new producer if provided and different // we might be able to iterate over properties and re-assign only the ones // that changed and are supported. let configToPatch = removeHookConfigToPatchToSource(currentConfig); instanceActions.patchConfig(configToPatch); if (currentConfig.payload) { instanceActions.mergePayload(currentConfig.payload); } let currentProducer = instance.fn; let pendingProducer = currentConfig.producer; if (pendingProducer !== undefined && pendingProducer !== currentProducer) { instanceActions.replaceProducer(pendingProducer); } } function removeHookConfigToPatchToSource(currentConfig) { // the patched config may contain the following properties // - source // - payload // - events // and other properties that can be retrieved from hooks usage and others // so we are tearing them apart before merging let { lazy, events, source, payload, concurrent, autoRunArgs, subscriptionKey, ...output } = currentConfig; return output; } function forceComponentUpdate(prev) { return prev + 1; } // dev mode helpers let currentlyRenderingComponentName = null; function __DEV__setHookCallerName(name) { if (name && !currentlyRenderingComponentName) { currentlyRenderingComponentName = name; } } function __DEV__getCurrentlyRenderingComponentName() { return currentlyRenderingComponentName; } function __DEV__unsetHookCallerName() { currentlyRenderingComponentName = null; } let __DEV__betterSourceConfigProperties; if (__DEV__) { __DEV__betterSourceConfigProperties = { runEffect: true, retryConfig: true, cacheConfig: true, initialValue: true, hideFromDevtools: true, skipPendingStatus: true, skipPendingDelayMs: true, resetStateOnDispose: true, runEffectDurationMs: true, }; } function __DEV__warnInDevAboutIncompatibleConfig(subscription) { let { config } = subscription; let { source, key, producer } = config; if (source) { if (key) { console.error(`[Warning][async-states] Subscription in component ${subscription.at} ` + `has a 'source' and 'key' as the same time, 'key' has no effect.`); return; } if (producer) { console.error(`[Warning][async-states] Subscription in component ${subscription.at} ` + `has a 'source' (${source.key}) and 'producer' as the same time,` + ` the source's producer will be replaced.`); return; } let configuredProps = Object.keys(config).filter((t) => config[t] !== undefined && __DEV__betterSourceConfigProperties[t]); if (configuredProps.length) { console.error(`[Warning][async-states] Subscription in component ${subscription.at} ` + `has a 'source' and the following properties '` + configuredProps.join(", ") + "' at the same time. All these props will be flushed into the " + "source config, better move them to the source creation."); return; } } } function useRetainInstance(instance, config, deps) { // ⚠️⚠️⚠️ // the subscription will be constructed fully in the first time (per instance) // then we will update its properties through the alternate after rendering // so basically, we won't care about any dependency array except the instance // itself. Because all the other information will be held by the alternate. // so, sorry typescript and all readers 🙂 let [, forceUpdate] = React__namespace.useState(0); return React__namespace.useMemo(() => createSubscription(instance, forceUpdate, config, deps), [instance]); } function createSubscription(instance, update, initialConfig, deps) { // these properties are to store the single onChange or onSubscribe // events (a single variable, but may be an array) // and every time you call onChange it overrides this value // sure, it receives the previous events as argument if function let changeEvents = null; let subscribeEvents = null; let incompleteSub = { deps, update, instance, cb: null, initial: true, config: initialConfig, version: instance.version, onChange, onSubscribe, alternate: null, get changeEvents() { return changeEvents; }, get subscribeEvents() { return subscribeEvents; }, // used in dev mode at: __DEV__getCurrentlyRenderingComponentName(), }; let subscription = incompleteSub; subscription.read = (readSubscriptionInConcurrentMode).bind(null, subscription); subscription.return = createLegacyReturn(subscription, initialConfig); return subscription; function onChange(newEvents) { if (isFunction(newEvents)) { let events = newEvents; let maybeEvents = events(changeEvents); if (maybeEvents) { changeEvents = maybeEvents; } } else if (newEvents) { changeEvents = newEvents; } } function onSubscribe(newEvents) { if (isFunction(newEvents)) { let events = newEvents; let maybeEvents = events(subscribeEvents); if (maybeEvents) { subscribeEvents = maybeEvents; } } else if (newEvents) { subscribeEvents = newEvents; } } } let suspendingPromises = new WeakMap(); function removePromiseFromSuspendersList(promise, subscription) { if (promise && promise.status !== "pending") { let suspender = suspendingPromises.get(promise); if (suspender === subscription) { suspendingPromises.delete(promise); } } } function readSubscriptionInConcurrentMode(subscription, config, suspend, throwError) { let alternate = subscription.alternate; let newConfig = alternate?.config || config; // this means we want to suspend and the subscription is fresh (created in // render and hasn't been committed yet, or dependencies changed) if (suspend && (subscription.initial || subscription.config !== newConfig)) { // this means that's the initial subscription to this state instance // either in this path or when deps change, we will need to run again // if the condition is truthy // subscription.config !== newConfig means that deps changed let instance = subscription.instance; let promise = instance.promise; let wasSuspending = !!promise && suspendingPromises.has(promise); let shouldRun = shouldRunSubscription(subscription, newConfig); if (shouldRun && !wasSuspending) { // The configuration may change the producer or an important option // So, it is important to reconcile before running. // In the normal flow, this reconciliation happens at the commit phase // but if we are to run during render, we should do it now. reconcileInstance(instance, newConfig); let runArgs = (newConfig.autoRunArgs || []); // we mark it as a render phase update so that subscriptions (already // mounted components) won't schedule a render right now, and thus // avoid scheduling a render on a component while rendering another let wasUpdatingOnRender = startRenderPhaseRun(); instance.actions.run.apply(null, runArgs); endRenderPhaseRun(wasUpdatingOnRender); promise = instance.promise; // this means that the run was sync, in this case, we would need // to notify the previous subscribers to force an update // If the run was aborted from the producer (props.abort()), currentAbort // would be null, and in this case we are there was no run or updates // so we will notify only when currentAbort is not null and the promise // is falsy (resolved sync) (or probably read from cache) if (!promise && instance.currentAbort !== null) { notifyOtherInstanceSubscriptionsInMicrotask(subscription); } } if (promise) { // we add this promise to the suspenders list in either ways: // - if pending, we will suspend, and we should only resolve from this path // - if we aren't pending and did resolve, this path means that the deps // changed, so we are having a commit where we will remove the promise, // but we should only allow resolve from this one path, because // if the promise resolves outside react, before this path commits // and another path commits while not being "suspensey", it will // cause infinite updates suspendingPromises.set(promise, subscription); if (promise.status === "pending") { throw promise; } } } let currentReturn = alternate ? alternate.return : subscription.return; if (throwError && currentReturn.isError) { throw currentReturn.error; } return currentReturn.state; } function notifyOtherInstanceSubscriptionsInMicrotask(subscription) { let { instance, cb } = subscription; let { subscriptions } = instance; let callbacks = []; if (subscriptions) { for (let sub of Object.values(subscriptions)) { let subCallback = sub.props.cb; if (subCallback !== cb) { callbacks.push(subCallback); } } } if (callbacks.length) { queueMicrotask(() => { callbacks.forEach((callback) => callback(instance.state)); }); } } function resolveSubscriptionKey(subscription) { let key = subscription.config.subscriptionKey || subscription.at || undefined; return `${key}-${(subscription.instance.subsIndex || 0) + 1}`; } function commit(subscription, pendingAlternate) { // here, we commit the alternate Object.assign(subscription, pendingAlternate); subscription.initial = false; if (subscription.alternate === pendingAlternate) { subscription.alternate = null; } // on commit, the first thing to do is to detect whether a state change // occurred before commit let version = subscription.version; let currentInstance = subscription.instance; removePromiseFromSuspendersList(currentInstance.promise, subscription); reconcileInstance(currentInstance, subscription.config); if (version !== currentInstance.version) { subscription.update(forceComponentUpdate); return; } } function autoRunAndSubscribeEvents(subscription) { let currentConfig = subscription.config; let currentInstance = subscription.instance; let instanceActions = currentInstance.actions; // we capture this state here to test it against updates in a fast way let committedState = currentInstance.state; // perform the subscription to the instance here let onStateChangeCallback = (onStateChange); // when the subscription key is provided, take it. // otherwise, in dev take the component name let subscriptionKey = subscription.config.subscriptionKey ?? __DEV__ ? resolveSubscriptionKey(subscription) : undefined; let callback = onStateChangeCallback.bind(null, subscription, committedState); // we keep track of this callback so can know later which subscription // did a render phase run and update and only notify others in microtask subscription.cb = callback; let unsubscribeFromInstance = instanceActions.subscribe({ cb: callback, key: subscriptionKey, }); let cleanups = [unsubscribeFromInstance]; let subscribeEvents = currentConfig.events?.subscribe; if (subscribeEvents) { let unsubscribeFromEvents = invokeSubscribeEvents(currentInstance, subscribeEvents); if (unsubscribeFromEvents) { cleanups = cleanups.concat(unsubscribeFromEvents); } } let subscriptionSubscribeEvents = subscription.subscribeEvents; if (subscriptionSubscribeEvents) { let unsubscribeFromEvents = invokeSubscribeEvents(currentInstance, subscriptionSubscribeEvents); if (unsubscribeFromEvents) { cleanups = cleanups.concat(unsubscribeFromEvents); } } // now, we will run the subscription. In order to run, all these conditions // should be met: // 1. lazy = false in the configuration // 2. condition() is true // 3. dependencies did change // 4. concurrent isn't enabled (it will run on render) if (!currentConfig.concurrent && shouldRunSubscription(subscription, currentConfig)) { let autoRunArgs = (currentConfig.autoRunArgs || []); let thisRunAbort = currentInstance.actions.run.apply(null, autoRunArgs); // add this run abort to the cleanups to it is aborted automatically cleanups.push(thisRunAbort); } return function cleanup() { for (let fn of cleanups) { if (fn) { fn(); } } }; } function onStateChange(subscription, committedState, newState) { let isRendering = isRenderPhaseRun(); // this occurs on concurrent mode when suspending. // no need to do anything, if it was sync for some reason, the commit // will catch a version change and trigger a sync local re-render if (isRendering) { return; } let currentReturn = subscription.return; let currentConfig = subscription.config; let currentInstance = subscription.instance; // the very first thing to do, is to invoke change events if relevant let changeEvents = currentConfig.events?.change; if (changeEvents) { invokeChangeEvents(currentInstance, changeEvents); } let subscriptionChangeEvents = subscription.changeEvents; if (subscriptionChangeEvents) { invokeChangeEvents(currentInstance, subscriptionChangeEvents); } let actualVersion = currentInstance.version; // when we detect that this state is mismatching what was rendered // then we need to force the render and computation if (doesStateMismatchSubscriptionReturn(newState, currentReturn)) { subscription.update(forceComponentUpdate); return; } // this will happen if we consume the latest cached state if (committedState === newState) { return; } // at this point, we have a new state, so we need to perform checks let comparingFunction = currentConfig.areEqual || Object.is; let currentSelector = currentConfig.selector || selectWholeState; let { cache, lastSuccess } = currentInstance; let newSelectedValue = currentSelector(newState, lastSuccess, cache); if (!comparingFunction(currentReturn.state, newSelectedValue)) { subscription.update(forceComponentUpdate); } else { // we would keep the same previous state, but we will upgrade all // closure variables used in this callback subscription.version = actualVersion; } } // this will detect whether the returned value from the hook doesn't match // the new state's status. function doesStateMismatchSubscriptionReturn(newState, subscriptionReturn) { switch (newState.status) { case "initial": { return !subscriptionReturn.isInitial; } case "pending": { return !subscriptionReturn.isPending; } case "success": { return !subscriptionReturn.isSuccess; } case "error": { return !subscriptionReturn.isError; } default: { return false; } } } function invokeChangeEvents(instance, events) { let nextState = instance.state; const changeHandlers = isArray(events) ? events : [events]; const eventProps = { state: nextState, source: instance.actions, }; changeHandlers.forEach((event) => { if (typeof event === "object") { const { handler, status } = event; if (!status || nextState.status === status) { // @ts-expect-error: it is extremely difficult to satisfy typescript // here without a switch case and treat each status a part handler(eventProps); } } else { // @ts-expect-error: it is extremely difficult to satisfy typescript // here without a switch case and treat each status a part event(eventProps); } }); } function invokeSubscribeEvents(instance, events) { if (!events || !instance) { return null; } let eventProps = instance.actions; let handlers = isArray(events) ? events : [events]; return handlers.map((handler) => handler(eventProps)); } function beginRender(subscription, newConfig, deps) { let instance = subscription.instance; // this means that the dependencies did not change and the same config // remains from the previous render, or this is the first render. if (newConfig === subscription.config) { // At this point, there is no need to create the alternate. // which will be equivalent to a render bailout. But we'll need to check // on the versions in case something bad happened. if (subscription.version === instance.version) { // null to bail out the render completeRender(subscription); return null; } } let alternate = { deps, instance, config: newConfig, return: subscription.return, update: subscription.update, version: subscription.version, }; subscription.alternate = alternate; // at this point, we have a defined alternate. Let's perform a render // first thing to do, is to verify the optimistic lock if (alternate.version !== instance.version) { // this means that the instance received an update in between, so we need // to change the returned value alternate.version = instance.version; alternate.return = createLegacyReturn(subscription, newConfig); // no need to check anything else since this is a fresh value completeRender(subscription); return alternate; } // next, we will check the selector function let pendingSelector = newConfig.selector || selectWholeState; if (pendingSelector !== subscription.config.selector) { let { cache, state, lastSuccess } = instance; let comparingFunction = newConfig.areEqual || Object.is; let newSelectedValue = pendingSelector(state, lastSuccess, cache); // this means that the selected value did change if (!comparingFunction(subscription.return.state, newSelectedValue)) { // todo: this will recalculate the selected state, make it not alternate.return = createLegacyReturn(subscription, newConfig); } } completeRender(subscription); return alternate; } function completeRender(subscription) { if (__DEV__) { __DEV__unsetHookCallerName(); } let { alternate } = subscription; let usedSubscription = alternate || subscription; let usedReturn = usedSubscription.return; let usedConfig = usedSubscription.config; if (usedConfig.concurrent) { // Reading via "read" may result in running the instance's producer. // So, it is important to reconcile before running. // Reconciliation is done inside the "read" function and only // when we should run. usedReturn.read(true, !!usedConfig.throwError); // in case of a render phase run/update, we would need to correct // the returned value if (subscription.instance.version !== usedSubscription.version) { usedSubscription.return = createLegacyReturn(subscription, usedConfig); } } } // this is the main hook, useAsyncState previously function useAsync_internal(options, deps, overrides) { // only parse the configuration when deps change // this process will yield the instance to subscribe to, along with // the combined config (options) let currentContext = React__namespace.useContext(Context); let depsToUse = deps.concat(currentContext); let { instance, config } = React__namespace.useMemo(() => parseConfig(currentContext, options, overrides), depsToUse); // here, we will create a subscription from this component // to this state instance. refer to HookSubscription type. let subscription = useRetainInstance(instance, config, depsToUse); if (__DEV__) { __DEV__warnInDevAboutIncompatibleConfig(subscription); } // the alternate is similar to React alternate object that's // created for every render and every fiber. It represents the // work in progress essential information that will be flushed // at the commit phase. It is important not to touch anything during // render and port everything to the commit phase. // a "null" alternate means that the render was bailed out. let alternate = beginRender(subscription, config, depsToUse); // this first effect will flush the alternate's properties inside // the subscription, such as the current return, the parsed config... // it is important to perform this work every time the alternate changes. // if your state changes often, this effect may be executed everytime. React__namespace.useLayoutEffect(() => commit(subscription, alternate), [alternate]); // this second effect which is governed by the user's dependencies will: // - subscribe to the state instance for changes // - invoke onSubscribe events // - run the state instance React__namespace.useEffect(() => autoRunAndSubscribeEvents(subscription), depsToUse); // the alternate may be null when we render the first time or when we bail out // the render afterward. // the returned priority is obviously for the alternate let returnedSubscription = alternate ?? subscription; return returnedSubscription.return; } // missing point: // initial return may have data as null (in a typing point of view) // this point will be challenging to be addressed, it should be typed deep down // to the Source itself and the StateInterface. which may be impossible // think about that later. a data: null may be okay for now. let concurrentOverrides = { concurrent: true, throwError: true, }; function useData_internal(options, deps, overrides) { try { setCurrentHookOverrides(concurrentOverrides); // this will mimic useAsync and get its result let result = useAsync_internal(options, deps, overrides); // the result here is guaranteed to be either initial or success return result; } finally { setCurrentHookOverrides(null); } } //endregion function useData(config, deps = emptyArray) { if (__DEV__) { __DEV__setHookCallerName(useCallerName(3)); } return useData_internal(config, deps); } //endregion function useAsync_export(config, deps = emptyArray) { if (__DEV__) { __DEV__setHookCaller