UNPKG

async-states

Version:

Core of async-states

1,368 lines (1,352 loc) 55.5 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.AsyncStates = {})); })(this, (function (exports) { 'use strict'; let __DEV__ = process.env.NODE_ENV !== "production"; let maybeWindow = typeof window !== "undefined" ? window : undefined; let isServer = typeof maybeWindow === "undefined" || "Deno" in maybeWindow; let emptyArray = []; function defaultHash(args, payload) { return JSON.stringify({ args, payload }); } function isPromise(candidate) { return !!candidate && isFunction(candidate.then); } function isFunction(fn) { return typeof fn === "function"; } function cloneProducerProps(props) { return { args: props.args, payload: props.payload, }; } const defaultAnonymousPrefix = "async-state-"; const nextKey = (function autoKey() { let key = 0; return function incrementAndGet() { key += 1; return `${defaultAnonymousPrefix}${key}`; }; })(); class LibraryDevtools { // the list of connected devtools clients, can be the npm package, // the devtools extension, or both for example listeners = []; // on connection from a client, we will give it the list of created // LibraryContexts that hold all instances that are created. // standalone instances and not stored in context will be gathered too // in a separate map, and on dispose they will be removed // this is relevant only on connection to give the full list of current // instances, after connexion, there is no need because the event // gives the instance itself and client should ensure instance is retained standalone = new Set(); contexts = new Set(); constructor() { this.connect = this.connect.bind(this); this.emitRun = this.emitRun.bind(this); this.emitSub = this.emitSub.bind(this); this.emitUnsub = this.emitUnsub.bind(this); this.emitUpdate = this.emitUpdate.bind(this); this.disconnect = this.disconnect.bind(this); this.startUpdate = this.startUpdate.bind(this); this.emitDispose = this.emitDispose.bind(this); this.emitCreation = this.emitCreation.bind(this); this.emitInstance = this.emitInstance.bind(this); this.captureContext = this.captureContext.bind(this); this.releaseContext = this.releaseContext.bind(this); this.ensureInstanceIsRetained = this.ensureInstanceIsRetained.bind(this); } connect(listener) { this.listeners.push(listener); // on any connection of any client, we give him the currently retained // instances. All of them. The list include: // - Any instance in any non-terminated LibraryContext // - Any active 'standalone' instance let instances = [...this.standalone]; for (let context of this.contexts) { instances.push(...context.getAll()); } listener.onConnect(instances); } disconnect(listener) { this.listeners = this.listeners.filter((t) => t !== listener); } emitCreation(instance) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.emitCreation(instance)); } emitDispose(instance) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.emitDispose(instance)); } emitInstance(instance) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.emitInstance(instance)); } emitRun(instance, cache) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.emitRun(instance, cache)); } emitSub(instance, key) { if (instance.config.hideFromDevtools) return; this.ensureInstanceIsRetained(instance); this.listeners.forEach((listener) => listener.emitSub(instance, key)); } emitUnsub(instance, key) { if (instance.config.hideFromDevtools) return; // undefined is considered truthy for backward compatibility // when an unsubscription occurs when not retained by a context, if // there is no subscription remaining, remove standalone instances if (instance.config.storeInContext === false && Object.keys(instance.subscriptions).length === 0) { this.standalone.delete(instance); } this.listeners.forEach((listener) => listener.emitUnsub(instance, key)); } startUpdate(instance) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.startUpdate(instance)); } emitUpdate(instance, replace) { if (instance.config.hideFromDevtools) return; this.listeners.forEach((listener) => listener.emitUpdate(instance, replace)); } captureContext(context) { this.contexts.add(context); } onConnect() { throw new Error("The library devtools agent doesn't have an onConnect."); } releaseContext(context) { this.contexts.delete(context); } ensureInstanceIsRetained(instance) { // undefined is considered truthy for backward compatibility if (instance.config.storeInContext === false) { this.standalone.add(instance); } } } let devtools$1; if (__DEV__) { devtools$1 = new LibraryDevtools(); } var devtools = devtools$1; let error = "error"; let pending = "pending"; let success = "success"; let initial = "initial"; let noop = () => { }; let now = () => Date.now(); let freeze = Object.freeze; let isArray = Array.isArray; function shallowClone(source1, source2) { return Object.assign({}, source1, source2); } let uniqueId = 0; function nextUniqueId() { return ++uniqueId; } function invokeChangeCallbacks(state, callbacks) { if (!callbacks) { return; } let { onError, onSuccess } = callbacks; if (onSuccess && state.status === success) { onSuccess(state); } if (onError && state.status === error) { onError(state); } } function invokeSingleChangeEvent(state, event) { if (isFunction(event)) { event(state); } else if (typeof event === "object" && event.status === state.status) { event.handler(state); } } function invokeInstanceEvents(instance, type) { let events = instance.events; if (!events || !events[type]) { return; } switch (type) { case "change": { Object.values(events[type]).forEach((registeredEvents) => { if (isArray(registeredEvents)) { registeredEvents.forEach((evt) => { invokeSingleChangeEvent(instance.actions.getState(), evt); }); } else { invokeSingleChangeEvent(instance.actions.getState(), registeredEvents); } }); return; } case "dispose": { Object.values(events[type]).forEach((registeredEvents) => { if (isArray(registeredEvents)) { registeredEvents.forEach((evt) => evt()); } else { registeredEvents(); } }); return; } case "cache-change": { Object.values(events[type]).forEach((registeredEvents) => { if (isArray(registeredEvents)) { registeredEvents.forEach((evt) => evt(instance.cache)); } else { registeredEvents(instance.cache); } }); return; } } } function hasCacheEnabled(instance) { return !!instance.config.cacheConfig?.enabled; } function getTopLevelParent(base) { let current = base; while (current.parent) { current = current.parent; } return current; } function computeRunHash(payload, props, hashFn) { let args = props?.args || emptyArray; let hashFunction = hashFn ?? defaultHash; return hashFunction(args, payload); } function getCachedState(instance, hash) { let topLevelParent = getTopLevelParent(instance); return topLevelParent.cache?.[hash]; } function removeCachedStateAndSpreadOnLanes(instance, hash) { let topLevelParent = getTopLevelParent(instance); if (!topLevelParent.cache) { return; } delete topLevelParent.cache[hash]; persistAndSpreadCache(topLevelParent); } function persistAndSpreadCache(topLevelParent) { if (topLevelParent.cache && isFunction(topLevelParent.config.cacheConfig?.persist)) { topLevelParent.config.cacheConfig.persist(topLevelParent.cache); } spreadCacheChangeOnLanes(topLevelParent); } function didCachedStateExpire(cachedState) { const { addedAt, deadline } = cachedState; return addedAt + deadline < now(); } function spreadCacheChangeOnLanes(topLevelParent) { invokeInstanceEvents(topLevelParent, "cache-change"); if (!topLevelParent.lanes) { return; } Object.values(topLevelParent.lanes).forEach((lane) => { lane.cache = topLevelParent.cache; spreadCacheChangeOnLanes(lane); }); } // from remix function hasHeadersSet(headers) { return headers && isFunction(headers.get); } function saveCacheAfterSuccessfulUpdate(instance) { let topLevelParent = getTopLevelParent(instance); let { config: { cacheConfig }, } = topLevelParent; let state = instance.state; let { props } = state; if (!topLevelParent.cache) { topLevelParent.cache = {}; } let hashFunction = cacheConfig?.hash || defaultHash; let runHash = hashFunction(props?.args, props?.payload); if (topLevelParent.cache[runHash]?.state !== state) { let deadline = getStateDeadline(state, cacheConfig?.timeout); let cachedState = (topLevelParent.cache[runHash] = { deadline, state: state, addedAt: now(), }); // avoid infinity deadline timeouts if (cacheConfig?.auto && Number.isFinite(deadline)) { // after this deadline is elapsed, we would removed the cached entry // from the cache: only if it has the same reference. // because invalidateCache or replaceCache may have been called in // between. let id = setTimeout(() => { let topLevelParentCache = topLevelParent.cache; if (!topLevelParentCache) { return; } let currentCacheAtHash = topLevelParentCache[runHash]; if (!currentCacheAtHash) { return; } if (id !== currentCacheAtHash.id) { // this means that this cached state's id changed, probably due // to some unknown error, such as persisting the cache, running // and forcing loading it again. return; } delete topLevelParentCache[runHash]; // todo: dispatch cache change event // only refresh the cached state if: // - we have subscriptions // - it is the latest state if (instance.subscriptions && Object.keys(instance.subscriptions).length) { // re-run only when the current state is the same as the cached one // or else, when the user will want it, it won't find it in the cache // and thus it will request it again. if (instance.state === state) { // being the latest state, means that it we are not pending, nor // error, which means there has been no runs in between. so we will // use replay. // an alternative would be taking args and payload from the state // and invoking the run again. if the replay is proved to cause // issues, we will use the other alternative instance.actions.replay(); } } }, deadline); cachedState.id = id; } if (cacheConfig && isFunction(cacheConfig.persist)) { cacheConfig.persist(topLevelParent.cache); } spreadCacheChangeOnLanes(topLevelParent); } } function getStateDeadline(state, timeout) { // fast path for numbers if (timeout && !isFunction(timeout)) { return timeout; } let { data } = state; let deadline = Infinity; if (!timeout && data && hasHeadersSet(data.headers)) { let maybeMaxAge = readCacheControlMaxAgeHeader(data.headers); if (maybeMaxAge && maybeMaxAge > 0) { deadline = maybeMaxAge; } } if (isFunction(timeout)) { deadline = timeout(state); } return deadline; } // https://stackoverflow.com/a/60154883/7104283 function readCacheControlMaxAgeHeader(headers) { let cacheControl = headers.get("cache-control"); if (cacheControl) { let matches = cacheControl.match(/max-age=(\d+)/); return matches ? parseInt(matches[1], 10) : undefined; } } function loadCache(instance) { if (!hasCacheEnabled(instance) || !isFunction(instance.config.cacheConfig?.load)) { return; } // inherit cache from the parent if exists! if (instance.parent) { let topLevelParent = getTopLevelParent(instance); instance.cache = topLevelParent.cache; return; } let loadedCache = instance.config.cacheConfig.load(); if (!loadedCache) { return; } if (isPromise(loadedCache)) { waitForAsyncCache(instance, loadedCache); } else { resolveCache(instance, loadedCache); } } function waitForAsyncCache(instance, promise) { promise.then((asyncCache) => { resolveCache(instance, asyncCache); }); } function resolveCache(instance, resolvedCache) { instance.cache = resolvedCache; const cacheConfig = instance.config.cacheConfig; if (isFunction(cacheConfig.onCacheLoad)) { cacheConfig.onCacheLoad({ cache: instance.cache, source: instance.actions, }); } } function subscribeToInstance(instance, options) { let props = isFunction(options) ? { cb: options } : options; if (!isFunction(props.cb)) { return; } if (!instance.subsIndex) { instance.subsIndex = 0; } if (!instance.subscriptions) { instance.subscriptions = {}; } instance.subsIndex += 1; let subscriptionKey = props.key; if (subscriptionKey === undefined) { subscriptionKey = `$${instance.subsIndex}`; } function cleanup() { delete instance.subscriptions[subscriptionKey]; if (__DEV__) devtools.emitUnsub(instance, subscriptionKey); if (instance.config.resetStateOnDispose) { instance.actions.dispose(); } } instance.subscriptions[subscriptionKey] = { props, cleanup }; if (__DEV__) devtools.emitSub(instance, subscriptionKey); return cleanup; } function subscribeToInstanceEvent(instance, eventType, eventHandler) { if (!instance.events) { instance.events = {}; } if (!instance.events[eventType]) { instance.events[eventType] = {}; } let events = instance.events[eventType]; if (!instance.eventsIndex) { instance.eventsIndex = 0; } let index = ++instance.eventsIndex; events[index] = eventHandler; return function () { delete events[index]; }; } function notifySubscribers(instance) { if (!instance.subscriptions) { return; } Object.values(instance.subscriptions).forEach((subscription) => { subscription.props.cb(instance.state); }); } let isCurrentlyEmitting = false; let isCurrentlyAlteringState = false; let isCurrentlyFlushingAQueue = false; function startEmitting() { let prevIsEmitting = isCurrentlyEmitting; isCurrentlyEmitting = true; return prevIsEmitting; } function stopEmitting(restoreToThisValue) { isCurrentlyEmitting = restoreToThisValue; } function isAlteringState() { return isCurrentlyAlteringState; } function startAlteringState() { let prevIsAltering = isCurrentlyAlteringState; isCurrentlyAlteringState = true; return prevIsAltering; } function stopAlteringState(restoreToThisValue) { isCurrentlyAlteringState = restoreToThisValue; } function getQueueTail(instance) { if (!instance.queue) { return null; } let current = instance.queue; while (current.next !== null) { current = current.next; } return current; } function addToQueueAndEnsureItsScheduled(instance, update) { if (!instance.queue) { instance.queue = update; } else { let tail = getQueueTail(instance); if (!tail) { return; } tail.next = update; } ensureQueueIsScheduled(instance); } function enqueueUpdate(instance, newState, callbacks) { let update = { callbacks, data: newState, kind: 0, next: null, }; addToQueueAndEnsureItsScheduled(instance, update); } function enqueueSetState(instance, newValue, status = success, callbacks) { let update = { callbacks, kind: 1, data: { data: newValue, status }, next: null, }; addToQueueAndEnsureItsScheduled(instance, update); } function enqueueSetData(instance, newValue) { let update = { kind: 2, data: newValue, next: null, }; addToQueueAndEnsureItsScheduled(instance, update); } function ensureQueueIsScheduled(instance) { if (!instance.queue) { return; } let queue = instance.queue; if (queue.id) { return; } let delay = instance.config.keepPendingForMs || 0; let elapsedTime = now() - instance.state.timestamp; let remainingTime = delay - elapsedTime; if (remainingTime > 0) { queue.id = setTimeout(() => flushUpdateQueue(instance), remainingTime); } else { flushUpdateQueue(instance); } } function flushUpdateQueue(instance) { if (!instance.queue) { return; } let current = instance.queue; instance.queue = null; let prevIsFlushing = isCurrentlyFlushingAQueue; isCurrentlyFlushingAQueue = true; while (current !== null) { // when we encounter a pending state that's not the last one // we can safely skip it an process the next update let canBailoutPendingStatus = current.kind !== 2 && // 2 is for setData current.data.status === pending && current.next !== null; // so we will only process the updates that we can't skip from the queue if (!canBailoutPendingStatus) { switch (current.kind) { // there update came from replaceState(newWholeState) case 0: { let { data, callbacks } = current; // when the queue isn't empty, there is a caveat that might happen // when the path setting the whole state is in the queue. // ** It may lead to inconsistency with the timestamp // to avoid this, the state object is recreated here. let newState = { ...data }; newState.timestamp = now(); freeze(newState); replaceInstanceState(instance, newState, false, callbacks); break; } // there update came from setState(value, status) case 1: { let { status, data } = current.data; setInstanceState(instance, data, status, current.callbacks); break; } // there update came from setData(value) case 2: { isCurrentSetStateSettingData = true; setInstanceState(instance, current.data, success); isCurrentSetStateSettingData = false; break; } } } current = current.next; } isCurrentlyFlushingAQueue = prevIsFlushing; // always notify after a state update notifySubscribers(instance); } function scheduleDelayedPendingUpdate(instance, newState, notify) { function callback() { // callback always sets the state with a pending status if (__DEV__) devtools.startUpdate(instance); let clonedState = shallowClone(newState); clonedState.timestamp = now(); instance.state = freeze(clonedState); // <-- status is pending! instance.pendingUpdate = null; instance.version += 1; invokeInstanceEvents(instance, "change"); if (__DEV__) devtools.emitUpdate(instance, false); if (notify) { notifySubscribers(instance); } } let timeoutId = setTimeout(callback, instance.config.skipPendingDelayMs); instance.pendingUpdate = { callback, id: timeoutId }; } function replaceInstanceState(instance, newState, notify = true, callbacks) { let { config } = instance; let isPending = newState.status === pending; if (isPending && config.skipPendingStatus) { return; } if (instance.queue) { enqueueUpdate(instance, newState, callbacks); return; } if (config.keepPendingForMs && instance.state.status === pending && !isCurrentlyFlushingAQueue) { enqueueUpdate(instance, newState, callbacks); return; } // pending update has always a pending status // setting the state should always clear this pending update // because it is stale, and we can safely skip it if (instance.pendingUpdate) { clearTimeout(instance.pendingUpdate.id); instance.pendingUpdate = null; } if (isPending && config.skipPendingDelayMs && isFunction(setTimeout) && config.skipPendingDelayMs > 0) { scheduleDelayedPendingUpdate(instance, newState, notify); return; } if (__DEV__) devtools.startUpdate(instance); instance.version += 1; instance.state = newState; invokeChangeCallbacks(newState, callbacks); invokeInstanceEvents(instance, "change"); if (__DEV__) devtools.emitUpdate(instance, false); if (instance.state.status === success) { instance.lastSuccess = instance.state; if (hasCacheEnabled(instance)) { saveCacheAfterSuccessfulUpdate(instance); } } if (notify && !isCurrentlyFlushingAQueue) { notifySubscribers(instance); } } let isCurrentSetStateSettingData = false; function setInstanceData(instance, newValue) { let previouslySettingData = isCurrentSetStateSettingData; isCurrentSetStateSettingData = true; setInstanceState(instance, newValue); isCurrentSetStateSettingData = previouslySettingData; } function setInstanceState(instance, newValue, status = success, callbacks) { if (instance.queue) { if (isCurrentSetStateSettingData) { let update = newValue; enqueueSetData(instance, update); isCurrentSetStateSettingData = false; return; } let update = newValue; enqueueSetState(instance, update, status, callbacks); return; } let wasAlteringState = startAlteringState(); if (instance.state.status === pending || (isFunction(instance.currentAbort) && !isCurrentlyEmitting)) { instance.actions.abort(); instance.currentAbort = undefined; } let effectiveValue = newValue; if (isFunction(newValue)) { // there two possible ways to update the state by updater: // 1. the legacy setState(prevState => newData); // 2. the new setData(prevData => newData); // To avoid duplicating this whole function, a module level variable // was introduced to distinguish between both of them // this variable is set to true when the call occurs from setData if (isCurrentSetStateSettingData) { let update = newValue; effectiveValue = update(instance.lastSuccess.data ?? null); } else { effectiveValue = newValue(instance.state); } } // setting state from this path passes without props as a direct call to // setState; which means there are no "props", but we only care about // args and payload, so we hack it here like this let partialProducerProps = { args: [effectiveValue], payload: shallowClone(instance.payload), }; const savedProps = cloneProducerProps(partialProducerProps); let newState = { status, timestamp: now(), props: savedProps, data: effectiveValue, }; // if the next state has a pending status, we need to populate the prev // property, we take the previous state, if it was also pending, take // its prev and set it as previous. if (newState.status === pending) { let previousState = instance.state; if (previousState.status === pending) { previousState = previousState.prev; } if (previousState) { newState.prev = previousState; } } replaceInstanceState(instance, newState, true, callbacks); stopAlteringState(wasAlteringState); } function disposeInstance(instance) { if (instance.subscriptions && Object.keys(instance.subscriptions).length) { // this means that this state is retained by some subscriptions return false; } let wasAltering = startAlteringState(); instance.actions.abort(); if (instance.queue) { clearTimeout(instance.queue.id); instance.queue = null; } let initialState = instance.config.initialValue; if (isFunction(initialState)) { initialState = initialState(instance.cache); } let initialSavedProps = { args: [initialState], payload: shallowClone(instance.payload), }; const newState = { status: initial, timestamp: now(), data: initialState, props: initialSavedProps, }; replaceInstanceState(instance, newState); // resetting the state may cause problems in the sense that lastSuccess.data // stays alive, but is now un-synced instance.lastSuccess = newState; if (__DEV__) devtools.emitDispose(instance); stopAlteringState(wasAltering); invokeInstanceEvents(instance, "dispose"); return true; } function createProps(instance, indicators, payload, runProps) { let getState = instance.actions.getState; let args = (runProps?.args || emptyArray); let controller = new AbortController(); let producerProps = { emit, args, abort, getState, payload: payload, signal: controller.signal, onAbort(callback) { if (isFunction(callback)) { controller.signal.addEventListener("abort", () => { callback(controller.signal.reason); }); } }, isAborted() { return indicators.aborted; }, get lastSuccess() { return instance.lastSuccess; }, }; return producerProps; function emit(value, status) { if (indicators.cleared) { return; } if (!indicators.done) { if (__DEV__) { console.error("Called props.emit before the producer resolves. This is" + " not supported in the library and will have no effect"); } return; } let prevIsEmitting = startEmitting(); let newValue = value; let newStatus = status; instance.actions.setState(newValue, newStatus, runProps); stopEmitting(prevIsEmitting); } function abort(reason) { if (indicators.aborted || indicators.cleared) { return; } if (!indicators.done) { indicators.aborted = true; let currentState = instance.state; if (currentState.status === pending) { currentState = currentState.prev; } // revert back to previous state when aborting only if we won't be updating // the state right next. let isCurrentlyAlteringState = isAlteringState(); if (!isCurrentlyAlteringState) { replaceInstanceState(instance, currentState, true, runProps); } } indicators.cleared = true; // before calling user land onAbort that may emit controller.abort(reason); instance.currentAbort = undefined; } } function run(producer, props, indicators, onSettled, retryConfig, callbacks) { let pendingPromise; let executionValue; try { executionValue = producer(props); // when producer runs, it can decide to bailout everything // by calling props.abort(reason?) as early as possible if (indicators.aborted) { return; } } catch (e) { // same, you can props.abort(); throw "Ignored error"; if (indicators.aborted) { return; } onFail(e); return; } if (isPromise(executionValue)) { pendingPromise = executionValue; } else { // final value onSuccess(executionValue); return; } pendingPromise = pendingPromise.then(onSuccess, onFail); pendingPromise.status = pending; return pendingPromise; function onSuccess(data) { if (pendingPromise) { pendingPromise.status = "fulfilled"; pendingPromise.value = data; } if (!indicators.aborted) { indicators.done = true; onSettled(data, success, cloneProducerProps(props), callbacks); } return data; } function onFail(error$1) { if (pendingPromise) { pendingPromise.status = "rejected"; pendingPromise.reason = error$1; } if (indicators.aborted) { return; } if (retryConfig?.enabled && shouldRetry(indicators.index, retryConfig, error$1)) { let backoff = getRetryBackoff(indicators.index, retryConfig, error$1); indicators.index += 1; let id = setTimeout(() => { run(producer, props, indicators, onSettled, retryConfig, callbacks); }, backoff); props.onAbort(() => { clearTimeout(id); }); return; } indicators.done = true; onSettled(error$1, error, cloneProducerProps(props), callbacks); } } function shouldRetry(attempt, retryConfig, error) { let { retry, maxAttempts } = retryConfig; let canRetry = !!maxAttempts && attempt <= maxAttempts; let shouldRetry = retry === undefined ? true : !!retry; if (isFunction(retry)) { shouldRetry = retry(attempt, error); } return canRetry && shouldRetry; } function getRetryBackoff(attempt, retryConfig, error) { let { backoff } = retryConfig; if (isFunction(backoff)) { return backoff(attempt, error); } return backoff || 0; } function runcInstance(instance, props) { let config = instance.config; let effectDurationMs = Number(config.runEffectDurationMs) || 0; let shouldRunImmediately = !config.runEffect || effectDurationMs === 0; if (shouldRunImmediately) { let clonedPayload = Object.assign({}, instance.payload); return runInstanceImmediately(instance, clonedPayload, props); } return runInstanceWithEffects(instance, config.runEffect, effectDurationMs, props); } function scheduleDelayedRun(instance, durationMs, props) { let abortCallback = null; let timeoutId = setTimeout(function theDelayedRunExecution() { instance.pendingTimeout = null; let clonedPayload = Object.assign({}, instance.payload); abortCallback = runInstanceImmediately(instance, clonedPayload, props); }, durationMs); instance.pendingTimeout = { at: now(), id: timeoutId }; return function abortCleanup(reason) { clearTimeout(timeoutId); instance.pendingTimeout = null; if (isFunction(abortCallback)) { abortCallback(reason); } }; } function runInstanceWithEffects(instance, effect, durationMs, props) { switch (effect) { case "delay": case "debounce": { if (instance.pendingTimeout) { let rightNow = now(); let deadline = instance.pendingTimeout.at + durationMs; if (rightNow < deadline) { clearTimeout(instance.pendingTimeout.id); } } return scheduleDelayedRun(instance, durationMs, props); } case "throttle": { if (instance.pendingTimeout) { let rightNow = now(); let deadline = instance.pendingTimeout.at + durationMs; if (rightNow <= deadline) { return function noop() { // do nothing when throttled }; } break; } else { return scheduleDelayedRun(instance, durationMs, props); } } } let clonedPayload = Object.assign({}, instance.payload); return runInstanceImmediately(instance, clonedPayload, props); } function cleanInstancePendingStateBeforeImmediateRun(instance) { if (instance.pendingUpdate) { clearTimeout(instance.pendingUpdate.id); instance.pendingUpdate = null; } instance.actions.abort(); instance.currentAbort = undefined; } function replaceStateBecauseNoProducerProvided(instance, props) { let args = (props?.args ?? emptyArray); // keep these for readability // we can't really type these // this is supported for backward compatibility let newStateData = args[0]; let newStateStatus = args[1]; instance.actions.setState(newStateData, newStateStatus, props); return noop; } function replaceStateAndBailoutRunFromCachedState(instance, cachedState) { let actualState = instance.state; let nextState = cachedState.state; // this means that the current state reference isn't the same if (actualState !== nextState) { // this sets the new state and notifies subscriptions // true for notifying // todo: update latest run replaceInstanceState(instance, nextState, true); } } function runInstanceImmediately(instance, payload, props) { // when there is no producer attached to the instance, skip everything // and replace state immediately. This will skip cache too. instance.promise = null; if (!instance.fn) { return replaceStateBecauseNoProducerProvided(instance, props); } let wasAltering = startAlteringState(); // the pendingUpdate has always a pending status, it is delayed because // of the config.skipPendingDelayMs configuration option. let hasPendingUpdate = instance.pendingUpdate !== null; let isCurrentlyPending = instance.state.status === pending; if (isCurrentlyPending || hasPendingUpdate) { cleanInstancePendingStateBeforeImmediateRun(instance); } // this should not be into the previous if, because we need all the time // to invoke the currentAbort so that cleanups of the previous run are invoked if (isFunction(instance.currentAbort)) { instance.currentAbort(); } if (hasCacheEnabled(instance)) { let cacheConfig = instance.config.cacheConfig; let runHash = computeRunHash(payload, props, cacheConfig?.hash); let cachedState = getCachedState(instance, runHash); // only use a cached entry if not expired if (cachedState && !didCachedStateExpire(cachedState)) { replaceStateAndBailoutRunFromCachedState(instance, cachedState); if (__DEV__) devtools.emitRun(instance, true); return; } // this means that the cache entry was expired, we need to removed it // from the cache if (cachedState) { removeCachedStateAndSpreadOnLanes(instance, runHash); } } let indicators = { index: 1, done: false, cleared: false, aborted: false }; let producerProps = createProps(instance, indicators, payload, props); instance.latestRun = { args: producerProps.args, payload: producerProps.payload, }; instance.currentAbort = producerProps.abort; let runResult = run(instance.fn, producerProps, indicators, onSettled, instance.config.retryConfig, props // callbacks ); if (runResult) { // Promise<TData> if (__DEV__) devtools.emitRun(instance, false); instance.promise = runResult; let currentState = instance.state; if (currentState.status === pending) { currentState = currentState.prev; } let savedProps = cloneProducerProps(producerProps); let pendingState = { data: null, timestamp: now(), props: savedProps, prev: currentState, status: pending, }; replaceInstanceState(instance, pendingState, true, props); stopAlteringState(wasAltering); return producerProps.abort; } if (__DEV__) devtools.emitRun(instance, false); stopAlteringState(wasAltering); return producerProps.abort; function onSettled(data, status, savedProps, callbacks) { let state = freeze({ status, data, props: savedProps, timestamp: now(), }); replaceInstanceState(instance, state, true, callbacks); } } let contexts = new WeakMap(); let globalContextKey = freeze({}); let globalContext = createContext(globalContextKey); function createNewContext(arg) { let instances = new Map(); let createdContext = { ctx: arg, payload: {}, get(key) { return instances.get(key); }, remove(key) { return instances.delete(key); }, set(key, inst) { instances.set(key, inst); }, getAll() { return [...instances.values()]; }, terminate() { instances = new Map(); }, }; if (__DEV__) devtools.captureContext(createdContext); return createdContext; } function createContext(arg) { // null means the static global context if (arg === null) { return globalContext; } if (typeof arg !== "object") { throw new Error("createContext requires an object. Received " + String(typeof arg)); } let existingContext = contexts.get(arg); if (existingContext) { return existingContext; } let newContext = createNewContext(arg); contexts.set(arg, newContext); return newContext; } function requestContext(arg) { if (arg === null) { return globalContext; } let context = contexts.get(arg); if (!context) { throw new Error("Context not found. Please make sure to call createContext before " + "requestContext."); } return context; } function terminateContext(arg) { if (!arg) { return false; } let desiredContext = contexts.get(arg); if (!desiredContext) { return false; } // this will un-reference all instances from the context desiredContext.terminate(); if (__DEV__) devtools.releaseContext(desiredContext); return contexts.delete(arg); } let HYDRATION_DATA_KEY = "__$$"; const attemptHydratedState = isServer ? attemptHydratedStateServer : attemptHydratedStateDOM; // unused parameters to keep the same exported signature function attemptHydratedStateServer(_instance) { return null; } function attemptHydratedStateDOM(instance) { if (!maybeWindow) { return null; } let { key, config, ctx } = instance; let contextName = ctx?.name ?? null; // why there are two possible keys ? // When coming from the server, sources there can be created from two ways: // globally via createSource, or locally via hooks. // either ways, the hydrated source belongs to a Provider's Context, which has // a name, so the hydrated data will come to the client with that key. // So, this source either has a context with a name (React.useId()) when // created initially inside a Provider, or else, we'll fallback to the // global context hydration in case the source wasn't created from inside // a Provider. let hydrationKey = HYDRATION_DATA_KEY; if (contextName !== null) { hydrationKey = contextName; } let savedHydrationData = maybeWindow[hydrationKey]; if (!savedHydrationData) { return null; } let instanceHydration = savedHydrationData[key]; if (!instanceHydration) { return null; } delete savedHydrationData[key]; if (Object.keys(savedHydrationData).length === 0) { delete maybeWindow[hydrationKey]; } let [state] = instanceHydration; let { status, props } = state; if (typeof state.data === "undefined") { state.data = undefined; if (status === initial && props.args[0] === null && !config.initialValue) { props.args[0] = undefined; } } return instanceHydration; } function initializeInstance(instance) { loadCache(instance); let maybeHydratedState = attemptHydratedState(instance); if (maybeHydratedState) { let [state, latestRun, payload] = maybeHydratedState; instance.state = state; instance.payload = payload; instance.latestRun = latestRun; if (instance.state.status === success) { instance.lastSuccess = instance.state; } else { let initializer = instance.config.initialValue; let initialData = isFunction(initializer) ? initializer(instance.cache) : initializer; let savedInitialProps = { args: [initialData], payload: shallowClone(instance.payload), }; instance.lastSuccess = { status: initial, data: initialData, timestamp: now(), props: savedInitialProps, }; if (state.status === pending) { let normalPromise = new Promise((resolve, reject) => { instance.res = { res: resolve, rej: reject }; }); let promise = normalPromise; promise.status = pending; instance.promise = promise; state.prev = instance.lastSuccess; } } } else { let initializer = instance.config.initialValue; let initialData = isFunction(initializer) ? initializer(instance.cache) : initializer; let savedInitialProps = { args: [initialData], payload: shallowClone(instance.payload), }; let initialState = { status: initial, data: initialData, timestamp: now(), props: savedInitialProps, }; instance.state = initialState; instance.lastSuccess = initialState; } } // this is the main instance that will hold and manipulate the state // it is referenced by its 'key' or name. // when a state with the same name exists, it is returned instead of creating // a new one. class AsyncState { ctx; // used only in __DEV__ mode journal = null; // this contains all methods, such as getState, setState and so on actions; id; key; version = 0; config; payload = null; cache = null; parent = null; lanes = null; state; queue = null; latestRun = null; lastSuccess; promise = null; res; currentAbort = null; fn = null; pendingUpdate = null; pendingTimeout = null; subsIndex = null; subscriptions = null; eventsIndex = null; events = null; global = null; constructor(key, producer, config) { let instanceConfig = shallowClone(config); // this means that the instance won't be stored in the LibraryContext // object, will be mostly used with anonymous instances if (instanceConfig.storeInContext !== false) { // fallback to globalContext (null) let context = config?.context || null; let libraryContext = requestContext(context); let existingInstance = libraryContext.get(key); // when an instance with the same key exists, reuse it if (existingInstance) { existingInstance.actions.patchConfig(config); existingInstance.actions.replaceProducer(producer || null); return existingInstance; } // start recording journal events early before end of creation if (__DEV__) { this.journal = []; } this.ctx = libraryContext; libraryContext.set(key, this); } else { this.ctx = null; } this.key = key; this.id = nextUniqueId(); this.fn = producer ?? null; this.config = instanceConfig; this.actions = new StateSource(this); // this function will loadCache if applied and also attempt // hydrated data if existing and also set the initial state // it was moved from here for simplicity and keep the constructor readable initializeInstance(this); if (__DEV__) devtools.emitCreation(this); } } class StateSource { key; uniqueId; inst; constructor(instance) { this.inst = instance; this.key = instance.key; this.uniqueId = instance.id; } getState = () => { return this.inst.state; }; replaceState = (newState, notify = true, callbacks) => { replaceInstanceState(this.inst, newState, notify, callbacks); }; setState = (newValue, status = success, callbacks) => { setInstanceState(this.inst, newValue, status, callbacks); }; setData = (newData) => { setInstanceData(this.inst, newData); }; on = (eventType,