@atlaskit/node-data-provider
Version:
Node data provider for @atlaskit/editor-core plugins and @atlaskit/renderer
369 lines (346 loc) • 12.9 kB
JavaScript
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];
});
}
}