packages/relay-runtime/store/experimental-live-resolvers/LiveResolverCache.js (303 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format */ // flowlint ambiguous-object-type:error 'use strict'; import type { ReaderRelayLiveResolver, ReaderRelayResolver, } from '../../util/ReaderNode'; import type {DataID, Variables} from '../../util/RelayRuntimeTypes'; import type { MutableRecordSource, Record, RelayResolverError, SingularReaderSelector, Snapshot, } from '../RelayStoreTypes'; import type {EvaluationResult, ResolverCache} from '../ResolverCache'; import type {LiveState} from './LiveResolverStore'; const recycleNodesInto = require('../../util/recycleNodesInto'); const {RELAY_LIVE_RESOLVER} = require('../../util/RelayConcreteNode'); const {generateClientID, generateClientObjectClientID} = require('../ClientID'); const RelayModernRecord = require('../RelayModernRecord'); const RelayRecordSource = require('../RelayRecordSource'); const { RELAY_RESOLVER_ERROR_KEY, RELAY_RESOLVER_INVALIDATION_KEY, RELAY_RESOLVER_SNAPSHOT_KEY, RELAY_RESOLVER_VALUE_KEY, getStorageKey, } = require('../RelayStoreUtils'); const LiveResolverStore = require('./LiveResolverStore'); const invariant = require('invariant'); const warning = require('warning'); // When this experiment gets promoted to stable, these keys will move into // `RelayStoreUtils`. const RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY = '__resolverLieStateSubscription'; const RELAY_RESOLVER_LIVE_STATE_VALUE = '__resolverLiveStateValue'; const RELAY_RESOLVER_LIVE_STATE_DIRTY = '__resolverLiveStateDirty'; /** * An experimental fork of store/ResolverCache.js intended to let us experiment * with Live Resolvers. */ type ResolverID = string; // $FlowFixMe[unclear-type] - will always be empty const emptySet: $ReadOnlySet<any> = new Set(); function addDependencyEdge( edges: Map<ResolverID, Set<DataID>> | Map<DataID, Set<ResolverID>>, from: ResolverID | DataID, to: ResolverID | DataID, ): void { let set = edges.get(from); if (!set) { set = new Set(); edges.set(from, set); } set.add(to); } class LiveResolverCache implements ResolverCache { _resolverIDToRecordIDs: Map<ResolverID, Set<DataID>>; _recordIDToResolverIDs: Map<DataID, Set<ResolverID>>; _getRecordSource: () => MutableRecordSource; _store: LiveResolverStore; constructor( getRecordSource: () => MutableRecordSource, store: LiveResolverStore, ) { this._resolverIDToRecordIDs = new Map(); this._recordIDToResolverIDs = new Map(); this._getRecordSource = getRecordSource; this._store = store; } readFromCacheOrEvaluate<T>( record: Record, field: ReaderRelayResolver | ReaderRelayLiveResolver, variables: Variables, evaluate: () => EvaluationResult<T>, getDataForResolverFragment: SingularReaderSelector => mixed, ): [ T /* Answer */, ?DataID /* Seen record */, ?RelayResolverError, ?Snapshot, ] { const recordSource = this._getRecordSource(); const recordID = RelayModernRecord.getDataID(record); const storageKey = getStorageKey(field, variables); let linkedID = RelayModernRecord.getLinkedRecordID(record, storageKey); let linkedRecord = linkedID == null ? null : recordSource.get(linkedID); if ( linkedRecord == null || this._isInvalid(linkedRecord, getDataForResolverFragment) ) { // Cache miss; evaluate the selector and store the result in a new record: linkedID = linkedID ?? generateClientID(recordID, storageKey); linkedRecord = RelayModernRecord.create(linkedID, '__RELAY_RESOLVER__'); const evaluationResult = evaluate(); if (field.kind === RELAY_LIVE_RESOLVER) { if (__DEV__) { invariant( isLiveStateValue(evaluationResult.resolverResult), 'Expected a @live Relay Resolver to return a value that implements LiveState.', ); } const liveState: LiveState<mixed> = // $FlowFixMe[incompatible-type] - casting mixed evaluationResult.resolverResult; this._setLiveStateValue(linkedRecord, linkedID, liveState); } else { RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_VALUE_KEY, evaluationResult.resolverResult, ); } RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_SNAPSHOT_KEY, evaluationResult.snapshot, ); RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_ERROR_KEY, evaluationResult.error, ); recordSource.set(linkedID, linkedRecord); // Link the resolver value record to the resolver field of the record being read: const nextRecord = RelayModernRecord.clone(record); RelayModernRecord.setLinkedRecordID(nextRecord, storageKey, linkedID); recordSource.set(RelayModernRecord.getDataID(nextRecord), nextRecord); // Put records observed by the resolver into the dependency graph: const resolverID = evaluationResult.resolverID; addDependencyEdge(this._resolverIDToRecordIDs, resolverID, linkedID); addDependencyEdge(this._recordIDToResolverIDs, recordID, resolverID); const seenRecordIds = evaluationResult.snapshot?.seenRecords; if (seenRecordIds != null) { for (const seenRecordID of seenRecordIds) { addDependencyEdge( this._recordIDToResolverIDs, seenRecordID, resolverID, ); } } } else if ( field.kind === RELAY_LIVE_RESOLVER && RelayModernRecord.getValue(linkedRecord, RELAY_RESOLVER_LIVE_STATE_DIRTY) ) { // If this is an Live Resolver, we might have a cache hit (the // fragment data hasn't changed since we last evaluated the resolver), // but it might still be "dirty" (the live state changed and we need // to call `.read()` again). linkedID = linkedID ?? generateClientID(recordID, storageKey); linkedRecord = RelayModernRecord.clone(linkedRecord); // $FlowFixMe[incompatible-type] - casting mixed const liveState: LiveState<mixed> = RelayModernRecord.getValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_VALUE, ); // Set the new value for this and future reads. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_VALUE_KEY, liveState.read(), ); // Mark the resolver as clean again. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_DIRTY, false, ); recordSource.set(linkedID, linkedRecord); } // $FlowFixMe[incompatible-type] - will always be empty const answer: T = linkedRecord[RELAY_RESOLVER_VALUE_KEY]; // $FlowFixMe[incompatible-type] - casting mixed const snapshot: ?Snapshot = linkedRecord[RELAY_RESOLVER_SNAPSHOT_KEY]; // $FlowFixMe[incompatible-type] - casting mixed const error: ?RelayResolverError = linkedRecord[RELAY_RESOLVER_ERROR_KEY]; return [answer, linkedID, error, snapshot]; } // Register a new Live State object in the store, subscribing to future // updates. _setLiveStateValue( linkedRecord: Record, linkedID: DataID, liveState: LiveState<mixed>, ) { // If there's an existing subscription, unsubscribe. // $FlowFixMe[incompatible-type] - casting mixed const previousUnsubscribe: () => void = RelayModernRecord.getValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY, ); if (previousUnsubscribe != null) { previousUnsubscribe(); } // Subscribe to future values // Note: We subscribe before reading, since subscribing could potentially // trigger a syncronous update. By reading second way we will always // observe the new value, without needing to double render. const handler = this._makeLiveStateHandler(linkedID); const unsubscribe = liveState.subscribe(handler); // Store the live state value for future re-reads. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_VALUE, liveState, ); // Store the current value, for this read, and future cached reads. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_VALUE_KEY, liveState.read(), ); // Mark the field as clean. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_DIRTY, false, ); // Store our our unsubscribe function for future cleanup. RelayModernRecord.setValue( linkedRecord, RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY, unsubscribe, ); } // Create a callback to handle notifications from the live source that the // value may have changed. _makeLiveStateHandler(linkedID: DataID): () => void { return () => { const currentSource = this._getRecordSource(); const currentRecord = currentSource.get(linkedID); if (!currentRecord) { // If there is no record yet, it means the subscribe function fired an // update syncronously on subscribe (before we even created the record). // In this case we can safely ignore this update, since we will be // reading the new value when we create the record. return; } const nextSource = RelayRecordSource.create(); const nextRecord = RelayModernRecord.clone(currentRecord); // Mark the field as dirty. The next time it's read, we will call // `LiveState.read()`. RelayModernRecord.setValue( nextRecord, RELAY_RESOLVER_LIVE_STATE_DIRTY, true, ); nextSource.set(linkedID, nextRecord); this._store.publish(nextSource); // In the future, this notify might be defferred if we are within a // transaction. this._store.notify(); }; } invalidateDataIDs( updatedDataIDs: Set<DataID>, // Mutated in place ): void { const recordSource = this._getRecordSource(); const visited: Set<string> = new Set(); const recordsToVisit = Array.from(updatedDataIDs); while (recordsToVisit.length) { const recordID = recordsToVisit.pop(); updatedDataIDs.add(recordID); for (const fragment of this._recordIDToResolverIDs.get(recordID) ?? emptySet) { if (!visited.has(fragment)) { for (const anotherRecordID of this._resolverIDToRecordIDs.get( fragment, ) ?? emptySet) { this._markInvalidatedResolverRecord(anotherRecordID, recordSource); if (!visited.has(anotherRecordID)) { recordsToVisit.push(anotherRecordID); } } } } } } _markInvalidatedResolverRecord( dataID: DataID, recordSource: MutableRecordSource, // Written to ) { const record = recordSource.get(dataID); if (!record) { warning( false, 'Expected a resolver record with ID %s, but it was missing.', dataID, ); return; } const nextRecord = RelayModernRecord.clone(record); RelayModernRecord.setValue( nextRecord, RELAY_RESOLVER_INVALIDATION_KEY, true, ); recordSource.set(dataID, nextRecord); } _isInvalid( record: Record, getDataForResolverFragment: SingularReaderSelector => mixed, ): boolean { if (!RelayModernRecord.getValue(record, RELAY_RESOLVER_INVALIDATION_KEY)) { return false; } // $FlowFixMe[incompatible-type] - storing values in records is not typed const snapshot: ?Snapshot = RelayModernRecord.getValue( record, RELAY_RESOLVER_SNAPSHOT_KEY, ); const originalInputs = snapshot?.data; const readerSelector: ?SingularReaderSelector = snapshot?.selector; if (originalInputs == null || readerSelector == null) { warning( false, 'Expected previous inputs and reader selector on resolver record with ID %s, but they were missing.', RelayModernRecord.getDataID(record), ); return true; } const latestValues = getDataForResolverFragment(readerSelector); const recycled = recycleNodesInto(originalInputs, latestValues); if (recycled !== originalInputs) { return true; } return false; } // Create an empty record consisting of just an `id` field, along with a // namespaced `__id` field and insert it into the store. createClientRecord(id: string, typeName: string): string { const key = generateClientObjectClientID(typeName, id); const recordSource = this._getRecordSource(); const newRecord = RelayModernRecord.create(key, typeName); RelayModernRecord.setValue(newRecord, 'id', id); recordSource.set(key, newRecord); return key; } } // Validate that a value is live state // $FlowFixMe function isLiveStateValue(v: Object): boolean { return ( v != null && typeof v.read === 'function' && typeof v.subscribe === 'function' ); } module.exports = { LiveResolverCache, };