UNPKG

@atlaskit/node-data-provider

Version:

Node data provider for @atlaskit/editor-core plugins and @atlaskit/renderer

369 lines (346 loc) 12.9 kB
import { isSSR } from '@atlaskit/editor-common/core-utils'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; /** * Represents the SSR data for a single provider. * It's a map where each key is a unique node data key and the value is the prefetched data for that node. * * @example * { * 'node-id-1': { value: 'some data' }, * 'node-id-2': { value: 'other data' } * } */ /** * Represents the cached data for a Node Data Provider. * Each key is a unique node data key, and the value is an object containing: * - `source`: Indicates whether the data was fetched from SSR or the network. * - `data`: The actual data, which can be either a resolved value or a Promise. * * @example * { * 'node-id-1': { source: 'ssr', data: { value: 'some data' } }, * 'node-id-2': { source: 'network', data: { value: 'other data' } } * } */ /** * Represents the payload passed to the callback function when data is fetched. * It can either contain an error or the fetched data. */ /** * A Node Data Provider is responsible for fetching and caching data associated with specific ProseMirror nodes. * It supports a cache-first-then-network strategy, with initial data potentially provided via SSR. * * @template Node The specific type of JSONNode this provider supports. * @template Data The type of data this provider fetches and manages. */ export class NodeDataProvider { /** * A unique name for the provider. Used for identification in SSR. */ /** * A type guard to check if a given JSONNode is supported by this provider. * Used to ensure that the provider can handle the node type before attempting to fetch data. * * @param node The node to check. * @returns `true` if the node is of the type supported by this provider, otherwise `false`. */ /** * Generates a unique key for a given node to be used for caching. * * @param node The node for which to generate a data key. * @returns A unique string key for the node's data. */ /** * Fetches data for a batch of nodes from the network or another asynchronous source. * * @param nodes An array of nodes for which to fetch data. * @returns A promise that resolves to an array of data corresponding to the input nodes. */ constructor() { this.cacheVersion = 0; this.cache = {}; this.networkRequestsInFlight = {}; } /** * Sets the SSR data for the provider. * This pre-populates the cache with data rendered on the server, preventing redundant network requests on the client. * Calling this method will invalidate the existing cache. * * @example * ``` * const ssrData = window.__SSR_NODE_DATA__ || {}; * nodeDataProvider.setSSRData(ssrData); * ``` * * @param ssrData A map of node data keys to their corresponding data. */ setSSRData(ssrData = {}) { if (editorExperiment('platform_synced_block', true)) { this.updateCache(ssrData, { strategy: 'replace', source: 'ssr' }); return; } this.cacheVersion++; this.cache = Object.fromEntries(Object.entries(ssrData).map(([key, data]) => [key, { data, source: 'ssr' }])); } /** * Clears all cached data. * This increments the internal cache version, invalidating any pending network requests. * * @example * ``` * function useMyNodeDataProvider(contentId: string) { * const nodeDataProvider = new MyNodeDataProvider(); * * // Reset the cache when the contentId changes (e.g., when the user navigates to a different page). * useEffect(() => { * nodeDataProvider.resetCache(); * }, [contentId]); * * return nodeDataProvider; * } * ``` */ resetCache() { this.cacheVersion++; this.cache = {}; } /** * Fetches data for a given node using a cache-first-then-network strategy. * * The provided callback may be called multiple times: * 1. Immediately with data from the SSR cache, if available. * 2. Asynchronously with data fetched from the network. * * @example * ``` * const nodeDataProvider = new MyNodeDataProvider(); * * nodeDataProvider.getData(node, (data) => { * console.log('Node data:', data); * }); * ``` * * @param node The node (or its ProseMirror representation) for which to fetch data. * @param callback The callback function to call with the fetched data or an error. */ getData(node, callback) { // Move implementation to a separate async method // to keep this method synchronous and avoid async/await in the public API. void this.getDataAsync(node, callback); } async getDataAsync(node, callback) { const jsonNode = 'toJSON' in node ? node.toJSON() : node; if (!this.isNodeSupported(jsonNode)) { // eslint-disable-next-line no-console console.error(`The ${this.constructor.name} doesn't support Node ${jsonNode.type}.`); return; } const dataKey = this.nodeDataKey(jsonNode); const dataFromCache = this.cache[dataKey]; if (dataFromCache !== undefined) { // If we have the data in the SSR data, we can use it directly callback({ data: dataFromCache.data }); } if (isSSR()) { // During SSR, we only use the cache and never fetch from the network. // Use loading state instead. return; } // If no data is available in the cache, or the data is from the network, // we need to fetch it from the network. if ((dataFromCache === null || dataFromCache === void 0 ? void 0 : dataFromCache.source) !== 'network') { // Store the current cache version before making the request, // so we can check if the cache has changed while we are waiting for the network response. const cacheVersionBeforeRequest = this.cacheVersion; // Create a unique key for the in-flight network request // based on the cache version and the data key. const networkRequestInFlightKey = `${cacheVersionBeforeRequest}-${dataKey}`; // Check if there is already a network request in flight for this data // to avoid duplicate requests. const networkRequestInFlight = this.networkRequestsInFlight[networkRequestInFlightKey]; if (networkRequestInFlight) { try { const data = await networkRequestInFlight; callback({ data }); } catch (error) { callback({ error: error instanceof Error ? error : new Error(String(error)) }); } return; } try { const dataPromise = this.fetchNodesData([jsonNode]).then(([value]) => value); // Store the promise in the in-flight requests map this.networkRequestsInFlight[networkRequestInFlightKey] = dataPromise; const data = await dataPromise; // We need to call the callback with the data with result even if the cache version has changed, // so all promises that are waiting for the data can resolve. callback({ data }); // If the cache version has changed, we don't want to use the data from the network // because it could be stale data. if (cacheVersionBeforeRequest === this.cacheVersion) { // Replace promise with the resolved data in the cache if (editorExperiment('platform_synced_block', true)) { this.updateCache({ [dataKey]: data }, { strategy: 'merge', source: 'network' }); } else { this.cache[dataKey] = { data, source: 'network' }; } } } catch (error) { // If an error occurs, we call the callback with the error callback({ error: error instanceof Error ? error : new Error(String(error)) }); } finally { // Ensure we clean up the in-flight request entry delete this.networkRequestsInFlight[networkRequestInFlightKey]; } } } /** * Fetches data for a given node and returns it as a Promise. * This is a convenience wrapper around the `data` method for use with async/await. * * Note: This promise resolves with the *first* available data, which could be from the SSR cache or the network. * It may not provide the most up-to-date data if a network fetch is in progress. * * Note: This method is only for migration purposes. Use {@link getData} in new code instead. * * @private * @deprecated Don't use this method, use {@link getData} method instead. * This method is only for migration purposes. * * @param node The node (or its ProseMirror representation) for which to fetch data. * @returns A promise that resolves with the node's data. */ getDataAsPromise_DO_NOT_USE_OUTSIDE_MIGRATIONS(node) { return new Promise((resolve, reject) => { try { this.getData(node, payload => { if (payload.error) { reject(payload.error); } else { resolve(payload.data); } }); } catch (error) { reject(error); } }); } /** * Checks the cache for the given node and returns its status. * * Possible return values: * - `false`: No cached data found for the node. * - `'ssr'`: Data is cached from server-side rendering (SSR). * - `'network'`: Data is cached from a network request. * * @param node The node (or its ProseMirror representation) to check in the cache. * @returns The cache status: `false`, `'ssr'`, or `'network'`. */ getCacheStatusForNode(node) { const dataFromCache = this.getNodeDataFromCache(node); return dataFromCache ? dataFromCache.source : false; } /** * Retrieves the cached data for a given node, if available. * * @param node The node (or its ProseMirror representation) for which to retrieve cached data. * @returns The cached data object containing `data` and `source`, or `undefined` if no cache entry exists. */ getNodeDataFromCache(node) { const jsonNode = 'toJSON' in node ? node.toJSON() : node; if (!this.isNodeSupported(jsonNode)) { // eslint-disable-next-line no-console console.error(`The ${this.constructor.name} doesn't support Node ${jsonNode.type}.`); return undefined; } const dataKey = this.nodeDataKey(jsonNode); return this.cache[dataKey]; } /** * Returns the keys of the cache. * * @returns An array of the keys of the cache. */ getNodeDataCacheKeys() { return Object.keys(this.cache); } /** * Updates the cache with new records using merge or replace strategies. * This method should be the only way to modify the cache directly. * This allow subclasses to use it when needed. e.g. abstract fetchNodesData implementation. * * @example * ``` * const newRecords = { * 'node-id-1': { value: 'updated data' }, * 'node-id-3': { value: 'new data' } * }; * nodeDataProvider.updateCache(newRecords, { strategy: 'merge', source: 'network' }); * ``` * * Supports two strategies: * - 'merge' (default): Merges new records into the existing cache. * - 'replace': Replaces the entire cache with the new records, invalidating any in-flight requests. * * @param records A map of node data keys to their corresponding data. * @param options Optional settings for the update operation. * @param options.strategy The strategy to use for updating the cache ('merge' or 'replace'). Defaults to 'merge'. * @param options.source The source of the data being added ('ssr' or 'network'). Defaults to 'network'. */ updateCache(records = {}, options) { var _options$strategy, _options$source; const strategy = (_options$strategy = options === null || options === void 0 ? void 0 : options.strategy) !== null && _options$strategy !== void 0 ? _options$strategy : 'merge'; const source = (_options$source = options === null || options === void 0 ? void 0 : options.source) !== null && _options$source !== void 0 ? _options$source : 'network'; if (strategy === 'merge') { for (const [key, data] of Object.entries(records)) { this.cache[key] = { data, source }; } return; } else if (strategy === 'replace') { // Replace the entire cache with the new records // This will increase the cache version to invalidate any in-flight requests this.resetCache(); this.cache = Object.fromEntries(Object.entries(records).map(([key, data]) => [key, { data, source }])); } } /** * Removes one or more entries from the cache. * * @param keys A single data key or array of data keys to remove from the cache. */ removeFromCache(keys) { keys.forEach(key => { delete this.cache[key]; }); } }