UNPKG

@data-client/core

Version:

Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch

1,263 lines (1,179 loc) 39.2 kB
'use strict'; var normalizr = require('@data-client/normalizr'); function expireReducer(state, action) { const meta = { ...state.meta }; Object.keys(meta).forEach(key => { if (action.testKey(key)) { meta[key] = { ...meta[key], // 1 instead of 0 so we can do 'falsy' checks to see if it is set expiresAt: 1 }; } }); return { ...state, meta }; } function createMeta(expiryLength, fetchedAt) { const now = Date.now(); return { fetchedAt: fetchedAt != null ? fetchedAt : now, date: now, expiresAt: now + expiryLength }; } const FETCH = 'rdc/fetch'; const SET = 'rdc/set'; const SET_RESPONSE = 'rdc/setresponse'; const OPTIMISTIC = 'rdc/optimistic'; const RESET = 'rdc/reset'; const SUBSCRIBE = 'rdc/subscribe'; const UNSUBSCRIBE = 'rdc/unsubscribe'; const INVALIDATE = 'rdc/invalidate'; const INVALIDATEALL = 'rdc/invalidateall'; const EXPIREALL = 'rdc/expireall'; const GC = 'rdc/gc'; const FETCH_TYPE = FETCH; const ensurePojo = // FormData doesn't exist in node /* istanbul ignore else we don't run coverage when we test node*/ typeof FormData !== 'undefined' ? body => body instanceof FormData ? Object.fromEntries(body.entries()) : body : /* istanbul ignore next */body => body; function createOptimistic(endpoint, args, fetchedAt) { var _endpoint$dataExpiryL, _endpoint$dataExpiryL2; /* istanbul ignore next */ if (process.env.NODE_ENV === 'development' && ((_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 0) < 0) { throw new Error('Negative expiry length are not allowed.'); } return { type: OPTIMISTIC, key: endpoint.key(...args), args: args.map(ensurePojo), endpoint, meta: createMeta((_endpoint$dataExpiryL2 = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL2 : 60000, fetchedAt) }; } function fetchReducer(state, action) { if (action.endpoint.getOptimisticResponse && action.endpoint.sideEffect) { const setAction = createOptimistic(action.endpoint, action.args, action.meta.fetchedAt); return { ...state, optimistic: [...state.optimistic, setAction] }; } return state; } function invalidateReducer(state, action) { const endpoints = { ...state.endpoints }; const meta = { ...state.meta }; const invalidateKey = key => { delete endpoints[key]; const itemMeta = { ...meta[key], expiresAt: 0, invalidated: true }; delete itemMeta.error; meta[key] = itemMeta; }; if (action.type === INVALIDATE) { invalidateKey(action.key); } else { Object.keys(endpoints).forEach(key => { if (action.testKey(key)) { invalidateKey(key); } }); } return { ...state, endpoints, meta }; } function setReducer(state, action, controller) { let value; if (typeof action.value === 'function') { const previousValue = controller.get(action.schema, ...action.args, state); if (previousValue === undefined) return state; value = action.value(previousValue); } else { value = action.value; } try { const { entities, indexes, entitiesMeta } = normalizr.normalize(action.schema, value, action.args, state, action.meta); return { entities, endpoints: state.endpoints, indexes, meta: state.meta, entitiesMeta, optimistic: state.optimistic, lastReset: state.lastReset }; // reducer must update the state, so in case of processing errors we simply compute the endpoints inline } catch (error) { // this is not always bubbled up, so let's double sure this doesn't fail silently /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { console.error(error); } return state; } } class AbortOptimistic extends Error {} function setResponseReducer(state, action, controller) { if (action.error) { return reduceError(state, action, action.response); } try { var _state$meta$action$ke; let response; // for true set's response is contained in action if (action.type === OPTIMISTIC) { // this should never happen /* istanbul ignore if */ if (!action.endpoint.getOptimisticResponse) return state; try { // compute optimistic response based on current state response = action.endpoint.getOptimisticResponse.call(action.endpoint, controller.snapshot(state, action.meta.fetchedAt), ...action.args); } catch (e) { // AbortOptimistic means 'do nothing', otherwise we count the exception as endpoint failure if (e.constructor === AbortOptimistic) { return state; } throw e; } } else { response = action.response; } const { result, entities, indexes, entitiesMeta } = normalizr.normalize(action.endpoint.schema, response, action.args, state, action.meta); const endpoints = { ...state.endpoints, [action.key]: result }; try { if (action.endpoint.update) { const updaters = action.endpoint.update(result, ...action.args); Object.keys(updaters).forEach(key => { endpoints[key] = updaters[key](endpoints[key]); }); } // no reason to completely fail because of user-code error // integrity of this state update is still guaranteed } catch (error) { console.error(`Endpoint.update() error: ${action.key}`); console.error(error); } return { entities, endpoints, indexes, meta: { ...state.meta, [action.key]: { date: action.meta.date, fetchedAt: action.meta.fetchedAt, expiresAt: action.meta.expiresAt, prevExpiresAt: (_state$meta$action$ke = state.meta[action.key]) == null ? void 0 : _state$meta$action$ke.expiresAt } }, entitiesMeta, optimistic: filterOptimistic(state, action), lastReset: state.lastReset }; // reducer must update the state, so in case of processing errors we simply compute the endpoints inline } catch (error) { if (typeof error === 'object') { error.message = `Error processing ${action.key}\n\nFull Schema: ${JSON.stringify(action.endpoint.schema, undefined, 2)}\n\nError:\n${error.message}`; if ('response' in action) error.response = action.response; error.status = 400; } // this is not always bubbled up, so let's double sure this doesn't fail silently /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { console.error(error); } return reduceError(state, action, error); } } function reduceError(state, action, error) { if (error.name === 'AbortError') { // In case we abort simply undo the optimistic update and act like no fetch even occured // We still want those watching promises from fetch directly to observed the abort, but we don't want to // Trigger errors in this case. This means theoretically improperly built aborts useSuspense() could suspend forever. return { ...state, optimistic: filterOptimistic(state, action) }; } return { ...state, meta: { ...state.meta, [action.key]: { date: action.meta.date, fetchedAt: action.meta.fetchedAt, expiresAt: action.meta.expiresAt, error, errorPolicy: action.endpoint.errorPolicy == null ? void 0 : action.endpoint.errorPolicy(error) } }, optimistic: filterOptimistic(state, action) }; } /** Filter all requests with same serialization that did not start after the resolving request */ function filterOptimistic(state, resolvingAction) { return state.optimistic.filter(optimisticAction => optimisticAction.key !== resolvingAction.key || (optimisticAction.type === OPTIMISTIC ? optimisticAction.meta.fetchedAt !== resolvingAction.meta.fetchedAt : optimisticAction.meta.date > resolvingAction.meta.date)); } function createReducer(controller) { return function reducer(state, action) { if (!state) state = initialState$1; switch (action.type) { case GC: // inline deletes are fine as these should have 0 refcounts action.entities.forEach(({ key, pk }) => { var _entities$key, _entitiesMeta$key; (_entities$key = state.entities[key]) == null || delete _entities$key[pk]; (_entitiesMeta$key = state.entitiesMeta[key]) == null || delete _entitiesMeta$key[pk]; }); action.endpoints.forEach(fetchKey => { delete state.endpoints[fetchKey]; delete state.meta[fetchKey]; }); return state; case FETCH: return fetchReducer(state, action); case OPTIMISTIC: // eslint-disable-next-line no-fallthrough case SET_RESPONSE: return setResponseReducer(state, action, controller); case SET: return setReducer(state, action, controller); case INVALIDATEALL: case INVALIDATE: return invalidateReducer(state, action); case EXPIREALL: return expireReducer(state, action); case RESET: return { ...initialState$1, lastReset: action.date }; default: // A reducer must always return a valid state. // Alternatively you can throw an error if an invalid action is dispatched. return state; } }; } const initialState$1 = { entities: {}, endpoints: {}, indexes: {}, meta: {}, entitiesMeta: {}, optimistic: [], lastReset: 0 }; var __INTERNAL__ = /*#__PURE__*/Object.freeze({ __proto__: null, MemoCache: normalizr.MemoCache, initialState: initialState$1 }); function createSubscription(endpoint, { args }) { return { type: SUBSCRIBE, key: endpoint.key(...args), args, endpoint }; } function createUnsubscription(endpoint, { args }) { return { type: UNSUBSCRIBE, key: endpoint.key(...args), args, endpoint }; } function createSetResponse(endpoint, { args, fetchedAt, response, error = false }) { var _endpoint$errorExpiry, _endpoint$dataExpiryL; const expiryLength = error ? (_endpoint$errorExpiry = endpoint.errorExpiryLength) != null ? _endpoint$errorExpiry : 1000 : (_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 60000; /* istanbul ignore next */ if (process.env.NODE_ENV === 'development' && expiryLength < 0) { throw new Error('Negative expiry length are not allowed.'); } return { type: SET_RESPONSE, key: endpoint.key(...args), response, args: args.map(ensurePojo), endpoint, meta: createMeta(expiryLength, fetchedAt), error }; } function createSet(schema, { args, fetchedAt, value }) { return { type: SET, value, args: args.map(ensurePojo), schema, meta: createMeta(60000, fetchedAt) }; } function createReset() { return { type: RESET, date: Date.now() }; } function createInvalidateAll(testKey) { return { type: INVALIDATEALL, testKey }; } function createInvalidate(endpoint, { args }) { return { type: INVALIDATE, key: endpoint.key(...args) }; } /** * Requesting a fetch to begin */ function createFetch(endpoint, { args }) { let resolve = 0; let reject = 0; const promise = new Promise((a, b) => { [resolve, reject] = [a, b]; }); const meta = { fetchedAt: Date.now(), resolve, reject, promise }; return { type: FETCH, key: endpoint.key(...args), args, endpoint, meta }; } function createExpireAll(testKey) { return { type: EXPIREALL, testKey }; } class ImmortalGCPolicy { // eslint-disable-next-line @typescript-eslint/no-empty-function init() {} // eslint-disable-next-line @typescript-eslint/no-empty-function cleanup() {} createCountRef() { return () => () => undefined; } } function selectMeta(state, fetchKey) { return state.meta[fetchKey]; } const unsetDispatch = action => { throw new Error(`Dispatching while constructing your middleware is not allowed. ` + `Other middleware would not be applied to this dispatch.`); }; const unsetState = () => { // This is only the value until it is set by the DataProvider /* istanbul ignore next */ return initialState$1; }; /** * Imperative control of Reactive Data Client store * @see https://dataclient.io/docs/api/Controller */ class Controller { /** * Dispatches an action to Reactive Data Client reducer. * * @see https://dataclient.io/docs/api/Controller#dispatch */ /** * Gets the latest state snapshot that is fully committed. * * This can be useful for imperative use-cases like event handlers. * This should *not* be used to render; instead useSuspense() or useCache() * @see https://dataclient.io/docs/api/Controller#getState */ /** * Singleton to maintain referential equality between calls */ /** * Handles garbage collection */ constructor({ dispatch = unsetDispatch, getState = unsetState, memo = new normalizr.MemoCache(), gcPolicy = new ImmortalGCPolicy() } = {}) { this._dispatch = dispatch; this.getState = getState; this.memo = memo; this.gcPolicy = gcPolicy; } // TODO: drop when drop support for destructuring (0.14 and below) set dispatch(dispatch) { /* istanbul ignore next */ this._dispatch = dispatch; } // TODO: drop when drop support for destructuring (0.14 and below) get dispatch() { return this._dispatch; } bindMiddleware({ dispatch, getState }) { this._dispatch = dispatch; this.getState = getState; } /*************** Action Dispatchers ***************/ /** * Fetches the endpoint with given args, updating the Reactive Data Client cache with the response or error upon completion. * @see https://dataclient.io/docs/api/Controller#fetch */ fetch = (endpoint, ...args) => { const action = createFetch(endpoint, { args }); this.dispatch(action); if (endpoint.schema) { return action.meta.promise.then(input => normalizr.denormalize(endpoint.schema, input, {}, args)); } return action.meta.promise; }; /** * Fetches only if endpoint is considered 'stale'; otherwise returns undefined * @see https://dataclient.io/docs/api/Controller#fetchIfStale */ fetchIfStale = (endpoint, ...args) => { const { data, expiresAt, expiryStatus } = this.getResponseMeta(endpoint, ...args, this.getState()); if (expiryStatus !== normalizr.ExpiryStatus.Invalid && Date.now() <= expiresAt) return data; return this.fetch(endpoint, ...args); }; /** * Forces refetching and suspense on useSuspense with the same Endpoint and parameters. * @see https://dataclient.io/docs/api/Controller#invalidate */ invalidate = (endpoint, ...args) => args[0] !== null ? this.dispatch(createInvalidate(endpoint, { args: args })) : Promise.resolve(); /** * Forces refetching and suspense on useSuspense on all matching endpoint result keys. * @see https://dataclient.io/docs/api/Controller#invalidateAll * @returns Promise that resolves when invalidation is commited. */ invalidateAll = options => this.dispatch(createInvalidateAll(key => options.testKey(key))); /** * Sets all matching endpoint result keys to be STALE. * @see https://dataclient.io/docs/api/Controller#expireAll * @returns Promise that resolves when expiry is commited. *NOT* fetch promise */ expireAll = options => this.dispatch(createExpireAll(key => options.testKey(key))); /** * Resets the entire Reactive Data Client cache. All inflight requests will not resolve. * @see https://dataclient.io/docs/api/Controller#resetEntireStore */ resetEntireStore = () => this.dispatch(createReset()); /** * Sets value for the Queryable and args. * @see https://dataclient.io/docs/api/Controller#set */ set(schema, ...rest) { const value = rest[rest.length - 1]; const action = createSet(schema, { args: rest.slice(0, rest.length - 1), value }); // TODO: reject with error if this fails in reducer return this.dispatch(action); } /** * Sets response for the Endpoint and args. * @see https://dataclient.io/docs/api/Controller#setResponse */ setResponse = (endpoint, ...rest) => { const response = rest[rest.length - 1]; const action = createSetResponse(endpoint, { args: rest.slice(0, rest.length - 1), response }); return this.dispatch(action); }; /** * Sets an error response for the Endpoint and args. * @see https://dataclient.io/docs/api/Controller#setError */ setError = (endpoint, ...rest) => { const response = rest[rest.length - 1]; const action = createSetResponse(endpoint, { args: rest.slice(0, rest.length - 1), response, error: true }); return this.dispatch(action); }; /** * Resolves an inflight fetch. * @see https://dataclient.io/docs/api/Controller#resolve */ resolve = (endpoint, meta) => { return this.dispatch(createSetResponse(endpoint, meta)); }; /** * Marks a new subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#subscribe */ subscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createSubscription(endpoint, { args: args })) : Promise.resolve(); /** * Marks completion of subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#unsubscribe */ unsubscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createUnsubscription(endpoint, { args: args })) : Promise.resolve(); /*************** More ***************/ /* TODO: abort = <E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] ): Promise<void> */ /** * Gets a snapshot (https://dataclient.io/docs/api/Snapshot) * @see https://dataclient.io/docs/api/Controller#snapshot */ snapshot = (state, fetchedAt) => { return new Snapshot(this, state, fetchedAt); }; /** * Gets the error, if any, for a given endpoint. Returns undefined for no errors. * @see https://dataclient.io/docs/api/Controller#getError */ getError(endpoint, ...rest) { if (rest[0] === null) return; const state = rest[rest.length - 1]; // this is typescript generics breaking const args = rest.slice(0, rest.length - 1); const key = endpoint.key(...args); const meta = selectMeta(state, key); const error = state.endpoints[key]; if (error !== undefined && (meta == null ? void 0 : meta.errorPolicy) === 'soft') return; return meta == null ? void 0 : meta.error; } /** * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. * @see https://dataclient.io/docs/api/Controller#getResponse */ getResponse(endpoint, ...rest) { // TODO: breaking: only return data return this.getResponseMeta(endpoint, ...rest); } /** * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. * @see https://dataclient.io/docs/api/Controller#getResponseMeta */ getResponseMeta(endpoint, ...rest) { const [state, args] = extractStateAndArgs(rest); const isActive = args.length !== 1 || args[0] !== null; const key = isActive ? endpoint.key(...args) : ''; const cacheEndpoints = isActive ? state.endpoints[key] : undefined; const schema = endpoint.schema; const meta = selectMeta(state, key); let expiresAt = meta == null ? void 0 : meta.expiresAt; // if we have no endpoint entry, and our endpoint has a schema - try querying the store const shouldQuery = cacheEndpoints === undefined && schema !== undefined; const input = shouldQuery ? // nothing in endpoints cache, so try querying if we have a schema to do so this.memo.buildQueryKey(schema, args, state, key) : cacheEndpoints; if (!isActive) { // when not active simply return the query input without denormalizing return { data: input, expiryStatus: normalizr.ExpiryStatus.Valid, expiresAt: Infinity, countRef: () => () => undefined }; } let isInvalid = false; if (shouldQuery) { isInvalid = !normalizr.validateQueryKey(input); // endpoint without entities } else if (!schema || !requiresDenormalize(schema)) { return { data: cacheEndpoints, expiryStatus: this.getExpiryStatus(cacheEndpoints === undefined, !!endpoint.invalidIfStale, meta), expiresAt: expiresAt || 0, countRef: this.gcPolicy.createCountRef({ key }) }; } const { data, paths } = this.memo.denormalize(schema, input, state.entities, args); if (!expiresAt) { // note: isInvalid can only be true if shouldQuery is true if (isInvalid) expiresAt = 1; // fallback to entity expiry time else expiresAt = entityExpiresAt(paths, state.entitiesMeta); } return { data, expiryStatus: this.getExpiryStatus(typeof data === 'symbol', !!endpoint.invalidIfStale || isInvalid, meta), expiresAt, countRef: this.gcPolicy.createCountRef({ key, paths }) }; } /** * Queries the store for a Querable schema * @see https://dataclient.io/docs/api/Controller#get */ get(schema, ...rest) { const [state, args] = extractStateAndArgs(rest); const { data } = this.memo.query(schema, args, state); return typeof data === 'symbol' ? undefined : data; } /** * Queries the store for a Querable schema; providing related metadata * @see https://dataclient.io/docs/api/Controller#getQueryMeta */ getQueryMeta(schema, ...rest) { const [state, args] = extractStateAndArgs(rest); const { data, paths } = this.memo.query(schema, args, state); return { data: typeof data === 'symbol' ? undefined : data, countRef: this.gcPolicy.createCountRef({ paths }) }; } getExpiryStatus(invalidData, invalidIfStale, meta = {}) { // https://dataclient.io/docs/concepts/expiry-policy#expiry-status // we don't track the difference between stale or fresh because that is tied to triggering // conditions return meta.invalidated || invalidData && !meta.error ? normalizr.ExpiryStatus.Invalid : invalidData || invalidIfStale ? normalizr.ExpiryStatus.InvalidIfStale : normalizr.ExpiryStatus.Valid; } } // benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths // earliest expiry dictates age function entityExpiresAt(paths, entitiesMeta) { let expiresAt = Infinity; for (const { key, pk } of paths) { var _entitiesMeta$key; const entityExpiry = (_entitiesMeta$key = entitiesMeta[key]) == null || (_entitiesMeta$key = _entitiesMeta$key[pk]) == null ? void 0 : _entitiesMeta$key.expiresAt; // expiresAt will always resolve to false with any comparison if (entityExpiry < expiresAt) expiresAt = entityExpiry; } return expiresAt; } /** Whether `denormalize()` must run to reconstruct the response. * * True iff some node in the schema tree defines `normalize` — meaning it * transforms the response when written (entity write, polymorphic hoist, * lazy delegation, etc.), so the cached value differs from the response. * Mirrors the dispatch in `getVisit.ts`: schema classes are detected here * by the same hook the visit walker uses, so we never need to walk their * instance fields. */ function requiresDenormalize(schema) { if (!schema) return false; if (Array.isArray(schema)) return schema.length !== 0 && requiresDenormalize(schema[0]); // Must reject primitives before probing `.normalize` — `String.prototype.normalize` exists. const t = typeof schema; if (t !== 'object' && t !== 'function') return false; if (typeof schema.normalize === 'function') return true; // Plain-object schema map (e.g. `{ data: [Tacos], page: { ... } }`). return Object.values(schema).some(x => requiresDenormalize(x)); } class Snapshot { static abort = new AbortOptimistic(); state; controller; fetchedAt; abort = Snapshot.abort; constructor(controller, state, fetchedAt = 0) { this.state = state; this.controller = controller; this.fetchedAt = fetchedAt; } /*************** Data Access ***************/ /** @see https://dataclient.io/docs/api/Snapshot#getResponse */ getResponse(endpoint, ...args) { return this.controller.getResponse(endpoint, ...args, this.state); } /** @see https://dataclient.io/docs/api/Snapshot#getResponseMeta */ getResponseMeta(endpoint, ...args) { return this.controller.getResponseMeta(endpoint, ...args, this.state); } /** @see https://dataclient.io/docs/api/Snapshot#getError */ getError(endpoint, ...args) { return this.controller.getError(endpoint, ...args, this.state); } /** * Retrieved memoized value for any Querable schema * @see https://dataclient.io/docs/api/Snapshot#get */ get(schema, ...args) { return this.controller.get(schema, ...args, this.state); } /** * Queries the store for a Querable schema; providing related metadata * @see https://dataclient.io/docs/api/Snapshot#getQueryMeta */ getQueryMeta(schema, ...args) { return this.controller.getQueryMeta(schema, ...args, this.state); } } /** Extract state and args from rest params, applying ensurePojo to args */ function extractStateAndArgs(rest) { const l = rest.length; const args = new Array(l - 1); for (let i = 0; i < l - 1; i++) { // handle FormData args[i] = ensurePojo(rest[i]); } // this is typescript generics breaking return [rest[l - 1], args]; } var _DevToolsManager; let DEFAULT_CONFIG = {}; if (process.env.NODE_ENV !== 'production') { var _globalThis$document; const extraEndpointKeys = ['dataExpiryLength', 'errorExpiryLength', 'errorPolicy', 'invalidIfStale', 'pollFrequency', 'getOptimisticResponse', 'update']; function serializeEndpoint(endpoint) { var _toJSON, _endpoint$schema; const serial = { name: endpoint.name, schema: (_toJSON = (_endpoint$schema = endpoint.schema) == null || _endpoint$schema.toJSON == null ? void 0 : _endpoint$schema.toJSON()) != null ? _toJSON : endpoint.schema, sideEffect: endpoint.sideEffect }; extraEndpointKeys.forEach(key => { if (key in endpoint) serial[key] = endpoint[key]; }); return serial; } const HASINTL = typeof Intl !== 'undefined'; DEFAULT_CONFIG = { name: `Data Client: ${(_globalThis$document = globalThis.document) == null ? void 0 : _globalThis$document.title}`, autoPause: true, features: { pause: true, // start/pause recording of dispatched actions lock: true, // lock/unlock dispatching actions and side effects persist: false, // persist states on page reloading export: true, // export history of actions in a file import: 'custom', // import history of actions from a file jump: true, // jump back and forth (time travelling) skip: true, // skip (cancel) actions reorder: true, // drag and drop actions in the history list dispatch: false, // dispatch custom actions or action creators test: false // generate tests for the selected actions }, actionSanitizer: action => { if (!('endpoint' in action)) return action; return { ...action, endpoint: serializeEndpoint(action.endpoint) }; }, serialize: { options: undefined, /* istanbul ignore next */ replacer: HASINTL ? (key, value) => { if (typeof value === 'number' && typeof key === 'string' && isFinite(value) && (key === 'date' || key.endsWith('At'))) { return Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', second: 'numeric', fractionalSecondDigits: 3 }).format(value); } return value; } : undefined } }; } /** Integrates with https://github.com/reduxjs/redux-devtools * * Options: https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md * * @see https://dataclient.io/docs/api/DevToolsManager */ class DevToolsManager { constructor(config, skipLogging) { var _options$name, _globalThis$document2; this.started = false; this.actions = []; this.maxBufferLength = 100; /* istanbul ignore next */ const options = { ...DEFAULT_CONFIG, ...config }; this.devtoolsName = (_options$name = options.name) != null ? _options$name : `Data Client: ${(_globalThis$document2 = globalThis.document) == null ? void 0 : _globalThis$document2.title}`; this.devTools = typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__.connect(options); // we cut it in half so we should double so we don't lose if (config != null && config.maxAge) this.maxBufferLength = config.maxAge * 2; if (skipLogging) this.skipLogging = skipLogging; } handleAction(action, state) { if (this.started) { this.devTools.send(action, state); } else { // avoid this getting too big in case this is long running // we cut in half so we aren't constantly reallocating if (this.actions.length > this.maxBufferLength) this.actions = this.actions.slice(this.maxBufferLength / 2); // queue actions this.actions.push([action, state]); } } /** Called when initial state is ready */ init(state) { if (process.env.NODE_ENV !== 'production') { var _ref, _ref$__DC_CONTROLLERS; ((_ref$__DC_CONTROLLERS = (_ref = globalThis).__DC_CONTROLLERS__) != null ? _ref$__DC_CONTROLLERS : _ref.__DC_CONTROLLERS__ = new Map()).set(this.devtoolsName, this.controller); } if (process.env.NODE_ENV !== 'production' && this.devTools) { this.devTools.init(state); this.devTools.subscribe(msg => { switch (msg.type) { case 'START': this.started = true; if (this.actions.length) { this.actions.forEach(([action, state]) => { this.handleAction(action, state); }); this.actions = []; } break; case 'STOP': this.started = false; break; case 'DISPATCH': if (msg.payload.type === 'RESET') { this.controller.resetEntireStore(); } break; } }); } } /** Ensures all subscriptions are cleaned up. */ cleanup() { if (process.env.NODE_ENV !== 'production') { const map = globalThis.__DC_CONTROLLERS__; if ((map == null ? void 0 : map.get(this.devtoolsName)) === this.controller) { map.delete(this.devtoolsName); } } } } _DevToolsManager = DevToolsManager; (() => { /* istanbul ignore if */ /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { _DevToolsManager.prototype.middleware = function (controller) { this.controller = controller; if (!this.devTools) return next => action => next(action); const reducer = createReducer(controller); let state = controller.getState(); return next => action => { var _this$skipLogging; const shouldSkip = (_this$skipLogging = this.skipLogging) == null ? void 0 : _this$skipLogging.call(this, action); const ret = next(action); if (this.started) { // we track state changes here since getState() will only update after a batch commit state = reducer(state, action); } else { state = controller.getState(); } ret.then(() => { if (shouldSkip) return; this.handleAction(action, state.optimistic.reduce(reducer, state)); }); return ret; }; }; } else { _DevToolsManager.prototype.middleware = () => next => action => next(action); } })(); async function collapseFixture(fixture, args, interceptorData) { let error = 'error' in fixture ? fixture.error : false; let response = fixture.response; if (typeof fixture.response === 'function') { try { response = await fixture.response.apply(interceptorData, args); // dispatch goes through user-code that can sometimes fail. // let's ensure we always handle errors } catch (e) { response = e; error = true; } } return { response, error }; } function createFixtureMap(fixtures = []) { const map = new Map(); const computed = []; for (const fixture of fixtures) { if ('args' in fixture) { if (typeof fixture.response !== 'function') { const key = fixture.endpoint.key(...fixture.args); map.set(key, fixture); } else { // this has to be a typo. probably needs to remove args console.warn(`Fixture found with function response, and explicit args. Interceptors should not specify args. ${fixture.endpoint.name}: ${JSON.stringify(fixture.args)} Treating as Interceptor`); computed.push(fixture); } } else { computed.push(fixture); } } return [map, computed]; } function MockController(Base, { fixtures = [], getInitialInterceptorData = () => ({}) }) { const [fixtureMap, interceptors] = createFixtureMap(fixtures); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return class MockedController extends Base { // legacy compatibility (re-declaration) // TODO: drop when drop support for destructuring (0.14 and below) fixtureMap = fixtureMap; interceptors = interceptors; interceptorData = getInitialInterceptorData(); constructor(...args) { super(...args); // legacy compatibility // TODO: drop when drop support for destructuring (0.14 and below) if (!this._dispatch) { this._dispatch = args[0].dispatch; } } // legacy compatibility - we need this to work with 0.14 and below as they do not have this setter // TODO: drop when drop support for destructuring (0.14 and below) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore set dispatch(dispatch) { this._dispatch = dispatch; } get dispatch() { return action => { var _actionTypes$FETCH; // support legacy that has _TYPE suffix if (action.type === ((_actionTypes$FETCH = FETCH) != null ? _actionTypes$FETCH : FETCH_TYPE)) { // eslint-disable-next-line prefer-const let { key, args } = action; let fixture; if (this.fixtureMap.has(key)) { fixture = this.fixtureMap.get(key); if (!args) args = fixture.args; // exact matches take priority; now test ComputedFixture } else { for (const cfix of this.interceptors) { if (cfix.endpoint.testKey(key)) { fixture = cfix; break; } } } // we have a match if (fixture !== undefined) { var _fixture$delay; const replacedAction = { ...action }; const delayMs = typeof fixture.delay === 'function' ? fixture.delay(...args) : (_fixture$delay = fixture.delay) != null ? _fixture$delay : 0; if ('fetchResponse' in fixture) { const { fetchResponse } = fixture; fixture = { endpoint: fixture.endpoint, response(...args) { const endpoint = action.endpoint.extend({ fetchResponse: (input, init) => { const ret = fetchResponse.call(this, input, init); return Promise.resolve(new Response(JSON.stringify(ret), { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }) })); } }); return endpoint(...args); } }; } const fetch = async () => { if (!fixture) { throw new Error('No fixture found'); } // delayCollapse determines when the fixture function is 'collapsed' (aka 'run') // collapsed: https://en.wikipedia.org/wiki/Copenhagen_interpretation if (fixture.delayCollapse) { await new Promise(resolve => setTimeout(resolve, delayMs)); } const result = await collapseFixture(fixture, args, this.interceptorData); if (!fixture.delayCollapse && delayMs) { await new Promise(resolve => setTimeout(resolve, delayMs)); } if (result.error) { throw result.response; } return result.response; }; if (typeof replacedAction.endpoint.extend === 'function') { replacedAction.endpoint = replacedAction.endpoint.extend({ fetch }); } else { // TODO: full testing of this replacedAction.endpoint = fetch; replacedAction.endpoint.__proto__ = action.endpoint; } // TODO: make super.dispatch (once we drop support for destructuring) return this._dispatch(replacedAction); } } // TODO: make super.dispatch (once we drop support for destructuring) return this._dispatch(action); }; } }; } const { initialState } = __INTERNAL__; function mockInitialState(fixtures = []) { const actions = []; const dispatch = action => { actions.push(action); return Promise.resolve(); }; const controller = new Controller({ dispatch }); const reducer = createReducer(controller); fixtures.forEach(fixture => { dispatchFixture(fixture, fixture.args, controller); }); return actions.reduce(reducer, initialState); } function dispatchFixture(fixture, args, controller, fetchedAt) { // eslint-disable-next-line prefer-const let { endpoint } = fixture; const { response, error } = fixture; if (controller.resolve) { controller.resolve(endpoint, { args, response, error, fetchedAt: Date.now() }); } else { if (error === true) { controller.setError(endpoint, ...args, response); } else { controller.setResponse(endpoint, ...args, response); } } } exports.MockController = MockController; exports.collapseFixture = collapseFixture; exports.createFixtureMap = createFixtureMap; exports.mockInitialState = mockInitialState;