From e3d2ba873d89de187f67f248cca1042821a81cc3 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 23 Sep 2024 09:07:07 +0200 Subject: [PATCH 01/10] feat(peview): add `unstable_observeDocument(s)` to preview store --- .../src/core/preview/createGlobalListener.ts | 1 + .../src/core/preview/createObserveDocument.ts | 87 +++++++++++++++++++ .../src/core/preview/documentPreviewStore.ts | 20 ++++- .../core/preview/utils/applyMendozaPatch.ts | 18 ++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/sanity/src/core/preview/createObserveDocument.ts create mode 100644 packages/sanity/src/core/preview/utils/applyMendozaPatch.ts diff --git a/packages/sanity/src/core/preview/createGlobalListener.ts b/packages/sanity/src/core/preview/createGlobalListener.ts index ac76417b386..7be43f93e31 100644 --- a/packages/sanity/src/core/preview/createGlobalListener.ts +++ b/packages/sanity/src/core/preview/createGlobalListener.ts @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) { includePreviousRevision: false, includeMutations: false, visibility: 'query', + effectFormat: 'mendoza', tag: 'preview.global', }, ) diff --git a/packages/sanity/src/core/preview/createObserveDocument.ts b/packages/sanity/src/core/preview/createObserveDocument.ts new file mode 100644 index 00000000000..6b384a7fbf8 --- /dev/null +++ b/packages/sanity/src/core/preview/createObserveDocument.ts @@ -0,0 +1,87 @@ +import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {memoize, uniq} from 'lodash' +import {EMPTY, finalize, type Observable, of} from 'rxjs' +import {concatMap, map, scan, shareReplay} from 'rxjs/operators' + +import {type ApiConfig} from './types' +import {applyMendozaPatch} from './utils/applyMendozaPatch' +import {debounceCollect} from './utils/debounceCollect' + +export function createObserveDocument({ + mutationChannel, + client, +}: { + client: SanityClient + mutationChannel: Observable +}) { + const getBatchFetcher = memoize( + function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) { + const _client = client.withConfig(apiConfig) + + function batchFetchDocuments(ids: [string][]) { + return _client.observable + .fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'}) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))), + ) + } + return debounceCollect(batchFetchDocuments, 100) + }, + (apiConfig) => apiConfig.dataset + apiConfig.projectId, + ) + + const MEMO: Record> = {} + + function observeDocument(id: string, apiConfig?: ApiConfig) { + const _apiConfig = apiConfig || { + dataset: client.config().dataset!, + projectId: client.config().projectId!, + } + const fetchDocument = getBatchFetcher(_apiConfig) + return mutationChannel.pipe( + concatMap((event) => { + if (event.type === 'welcome') { + return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document}))) + } + return event.documentId === id ? of(event) : EMPTY + }), + scan((current: SanityDocument | undefined, event) => { + if (event.type === 'sync') { + return event.document + } + if (event.type === 'mutation') { + return applyMutationEvent(current, event) + } + //@ts-expect-error - this should never happen + throw new Error(`Unexpected event type: "${event.type}"`) + }, undefined), + ) + } + return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) { + const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id + if (!(key in MEMO)) { + MEMO[key] = observeDocument(id, apiConfig).pipe( + finalize(() => delete MEMO[key]), + shareReplay({bufferSize: 1, refCount: true}), + ) + } + return MEMO[key] + } +} + +function applyMutationEvent(current: SanityDocument | undefined, event: MutationEvent) { + if (event.previousRev !== current?._rev) { + console.warn('Document out of sync, skipping mutation') + return current + } + if (!event.effects) { + throw new Error( + 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', + ) + } + const next = applyMendozaPatch(current, event.effects.apply) + // next will be undefined in case of deletion + return next ? {...next, _rev: event.resultRev} : undefined +} diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index 154c7e56c26..fbedd6b7429 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,11 +1,12 @@ import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' import {type PrepareViewOptions, type SanityDocument} from '@sanity/types' -import {type Observable} from 'rxjs' +import {combineLatest, type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' import {isRecord} from '../util' import {createPreviewAvailabilityObserver} from './availability' import {createGlobalListener} from './createGlobalListener' +import {createObserveDocument} from './createObserveDocument' import {createPathObserver} from './createPathObserver' import {createPreviewObserver} from './createPreviewObserver' import {createObservePathsDocumentPair} from './documentPair' @@ -56,6 +57,18 @@ export interface DocumentPreviewStore { id: string, paths: PreviewPath[], ) => Observable> + /** + * Observe a complete document with the given ID + * @hidden + * @beta + */ + unstable_observeDocument: (id: string) => Observable + /** + * Observe a list of complete documents with the given IDs + * @hidden + * @beta + */ + unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]> } /** @internal */ @@ -79,6 +92,8 @@ export function createDocumentPreviewStore({ map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)), ) + const observeDocument = createObserveDocument({client, mutationChannel: globalListener}) + const observeFields = createObserveFields({client: versionedClient, invalidationChannel}) const observePaths = createPathObserver({observeFields}) @@ -110,6 +125,9 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, + unstable_observeDocument: observeDocument, + unstable_observeDocuments: (ids: string[]) => + combineLatest(ids.map((id) => observeDocument(id))), unstable_observeDocumentPairAvailability: observeDocumentPairAvailability, unstable_observePathsDocumentPair: observePathsDocumentPair, } diff --git a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts new file mode 100644 index 00000000000..0c1be69450c --- /dev/null +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -0,0 +1,18 @@ +import {type SanityDocument} from '@sanity/types' +import {applyPatch, type RawPatch} from 'mendoza' + +function omitRev(document: SanityDocument | undefined) { + if (document === undefined) { + return undefined + } + const {_rev, ...doc} = document + return doc +} + +export function applyMendozaPatch( + document: SanityDocument | undefined, + patch: RawPatch, +): SanityDocument | undefined { + const next = applyPatch(omitRev(document), patch) + return next === null ? undefined : next +} From ec7e0c012e05d381f7c41ae1762dc07913734a54 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 23 Sep 2024 11:09:13 +0200 Subject: [PATCH 02/10] feat(core): preserve visited documents in document-store --- .../document/document-pair/editState.ts | 3 +- .../document/document-pair/validation.ts | 5 +- .../store/_legacy/document/document-store.ts | 16 +- .../document/getVisitedDocuments.test.ts | 211 ++++++++++++++++++ .../_legacy/document/getVisitedDocuments.ts | 60 +++++ .../documentPanel/documentViews/FormView.tsx | 6 +- .../header/DocumentHeaderTabs.tsx | 4 +- .../header/DocumentHeaderTitle.tsx | 7 +- .../header/DocumentPanelHeader.tsx | 2 +- .../panes/document/useDocumentTitle.ts | 7 +- 10 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts create mode 100644 packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index baedd21604c..36c9656c297 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,7 +1,7 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' import {combineLatest, type Observable} from 'rxjs' -import {map, publishReplay, refCount, startWith, switchMap} from 'rxjs/operators' +import {map, publishReplay, refCount, startWith, switchMap, take} from 'rxjs/operators' import {type PairListenerOptions} from '../getPairListener' import {type IdPair, type PendingMutationsEvent} from '../types' @@ -40,6 +40,7 @@ export const editState = memoize( }, idPair: IdPair, typeName: string, + visited$: Observable<(SanityDocument | undefined)[]>, ): Observable => { const liveEdit = isLiveEditEnabled(ctx.schema, typeName) return snapshotPair( diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts index 084ad22cece..ca1933a0598 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts @@ -1,5 +1,5 @@ import {type SanityClient} from '@sanity/client' -import {type Schema} from '@sanity/types' +import {type SanityDocument, type Schema} from '@sanity/types' import {omit} from 'lodash' import {asyncScheduler, type Observable} from 'rxjs' import {distinctUntilChanged, map, shareReplay, throttleTime} from 'rxjs/operators' @@ -36,8 +36,9 @@ export const validation = memoize( }, {draftId, publishedId}: IdPair, typeName: string, + visited$: Observable<(SanityDocument | undefined)[]>, ): Observable => { - const document$ = editState(ctx, {draftId, publishedId}, typeName).pipe( + const document$ = editState(ctx, {draftId, publishedId}, typeName, visited$).pipe( map(({draft, published}) => draft || published), throttleTime(DOC_UPDATE_DELAY, asyncScheduler, {trailing: true}), distinctUntilChanged((prev, next) => { diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index e80ba2a338d..e1d4d584696 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -23,6 +23,7 @@ import { } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' import {validation} from './document-pair/validation' +import {getVisitedDocuments} from './getVisitedDocuments' import {type PairListenerOptions} from './getPairListener' import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue' import {listenQuery, type ListenQueryOptions} from './listenQuery' @@ -103,6 +104,10 @@ export function createDocumentStore({ const observeDocumentPairAvailability = documentPreviewStore.unstable_observeDocumentPairAvailability + const visitedDocuments = getVisitedDocuments({ + observeDocuments: documentPreviewStore.unstable_observeDocuments, + }) + // Note that we're both passing a shared `client` here which is used by the // internal operations, and a `getClient` method that we expose to user-land // for things like validations @@ -162,7 +167,14 @@ export function createDocumentStore({ return editOperations(ctx, getIdPairFromPublished(publishedId), type) }, editState(publishedId, type) { - return editState(ctx, getIdPairFromPublished(publishedId), type) + const edit = editState( + ctx, + getIdPairFromPublished(publishedId), + type, + visitedDocuments.visited$, + ) + visitedDocuments.add(publishedId) + return edit }, operationEvents(publishedId, type) { return operationEvents({ @@ -185,7 +197,7 @@ export function createDocumentStore({ ) }, validation(publishedId, type) { - return validation(ctx, getIdPairFromPublished(publishedId), type) + return validation(ctx, getIdPairFromPublished(publishedId), type, visitedDocuments.visited$) }, }, } diff --git a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts new file mode 100644 index 00000000000..2479434551c --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts @@ -0,0 +1,211 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {type SanityDocument} from '@sanity/types' +import {of} from 'rxjs' + +import {getVisitedDocuments} from './getVisitedDocuments' + +const mockObserveDocuments = jest.fn((ids: string[]) => { + // Return an observable that emits an array of documents corresponding to the IDs + return of( + ids.map( + (id) => + ({ + _id: id, + _type: 'foo', + }) as unknown as SanityDocument, + ), + ) +}) + +type Emissions = (SanityDocument | undefined)[][] + +describe('getVisitedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should start with an empty array', () => { + const {visited$} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + expect(emissions).toHaveLength(1) + expect(emissions[0]).toEqual([]) + }) + + it('should emit documents when an ID is added', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + add('doc1') + + expect(emissions).toHaveLength(2) + + const expectedDocs = [ + {_id: 'doc1', _type: 'foo'}, + {_id: 'drafts.doc1', _type: 'foo'}, + ] + + expect(emissions[1]).toEqual(expectedDocs) + }) + + it('should emit documents when multiple IDs are added', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + add('doc1') + add('doc2') + + expect(emissions).toHaveLength(3) + + const expectedDocs = [ + {_id: 'doc1', _type: 'foo'}, + {_id: 'drafts.doc1', _type: 'foo'}, + {_id: 'doc2', _type: 'foo'}, + {_id: 'drafts.doc2', _type: 'foo'}, + ] + + expect(emissions[2]).toEqual(expectedDocs) + }) + + it('should move an existing ID to the end when re-added', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + add('doc1') + add('doc2') + add('doc3') + add('doc2') // Re-add 'doc2' + + // Expected IDs after re-adding 'doc2': ['doc1', 'doc3', 'doc2'] + const expectedDocs = [ + {_id: 'doc1', _type: 'foo'}, + {_id: 'drafts.doc1', _type: 'foo'}, + {_id: 'doc3', _type: 'foo'}, + {_id: 'drafts.doc3', _type: 'foo'}, + {_id: 'doc2', _type: 'foo'}, + {_id: 'drafts.doc2', _type: 'foo'}, + ] + + expect(emissions[emissions.length - 1]).toEqual(expectedDocs) + }) + + it('should not duplicate documents when the same ID is added multiple times', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + add('doc1') + add('doc1') + add('doc1') + + expect(emissions).toHaveLength(4) + + const expectedDocs = [ + {_id: 'doc1', _type: 'foo'}, + {_id: 'drafts.doc1', _type: 'foo'}, + ] + + expect(emissions[emissions.length - 1]).toEqual(expectedDocs) + }) + + it('should maintain up to MAX_OBSERVED_DOCUMENTS IDs', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissions: Emissions = [] + const docIds = ['doc1', 'doc2', 'doc3', 'doc4', 'doc5', 'doc6'] + + visited$.subscribe((docs) => { + emissions.push(docs) + }) + + docIds.forEach((id) => add(id)) + + expect(mockObserveDocuments).toHaveBeenLastCalledWith([ + 'doc2', + 'drafts.doc2', + 'doc3', + 'drafts.doc3', + 'doc4', + 'drafts.doc4', + 'doc5', + 'drafts.doc5', + 'doc6', + 'drafts.doc6', + ]) + + // The last emission should only contain the last 5 documents (doc2 to doc6) in the correct order, removes the oldest doc (doc1) + const expectedDocs = [ + {_id: 'doc2', _type: 'foo'}, + {_id: 'drafts.doc2', _type: 'foo'}, + {_id: 'doc3', _type: 'foo'}, + {_id: 'drafts.doc3', _type: 'foo'}, + {_id: 'doc4', _type: 'foo'}, + {_id: 'drafts.doc4', _type: 'foo'}, + {_id: 'doc5', _type: 'foo'}, + {_id: 'drafts.doc5', _type: 'foo'}, + {_id: 'doc6', _type: 'foo'}, + {_id: 'drafts.doc6', _type: 'foo'}, + ] + + expect(emissions[emissions.length - 1]).toEqual(expectedDocs) + }) + + it('should keep the observer alive even when no one is subscribed', () => { + const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) + + const emissionsFirstSub: Emissions = [] + const emissionsSecondSub: Emissions = [] + + const subscription = visited$.subscribe((docs) => { + emissionsFirstSub.push(docs) + }) + + add('doc1') + add('doc2') + + expect(emissionsFirstSub).toHaveLength(3) + + const expectedDocs = [ + {_id: 'doc1', _type: 'foo'}, + {_id: 'drafts.doc1', _type: 'foo'}, + {_id: 'doc2', _type: 'foo'}, + {_id: 'drafts.doc2', _type: 'foo'}, + ] + + expect(emissionsFirstSub[emissionsFirstSub.length - 1]).toEqual(expectedDocs) + + // unsubscribe + subscription.unsubscribe() + + visited$.subscribe((docs) => { + emissionsSecondSub.push(docs) + }) + // Should have the last emitted documents + expect(emissionsSecondSub).toHaveLength(1) + expect(emissionsSecondSub[emissionsSecondSub.length - 1]).toEqual(expectedDocs) + }) +}) diff --git a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts new file mode 100644 index 00000000000..146cb9142c9 --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts @@ -0,0 +1,60 @@ +import {type SanityDocument} from '@sanity/types' +import {type Observable, ReplaySubject, Subject} from 'rxjs' +import {map, scan, share, startWith, switchMap} from 'rxjs/operators' + +import {type DocumentPreviewStore} from '../../../preview' +import {getDraftId, getPublishedId} from '../../../util' + +const MAX_OBSERVED_DOCUMENTS = 5 + +/** + * Keeps a listener of the documents the user visited through the form. + * Allowing us to provide a quick way to navigate back to the last visited documents. + */ +export function getVisitedDocuments({ + observeDocuments, +}: { + observeDocuments: DocumentPreviewStore['unstable_observeDocuments'] +}): { + add: (id: string) => void + visited$: Observable<(SanityDocument | undefined)[]> +} { + const observedDocumentsSubject = new Subject() + + const visited$ = observedDocumentsSubject.pipe( + scan((prev: string[], nextDoc: string) => { + const nextDocs = [...prev] + if (nextDocs.includes(nextDoc)) { + // Doc is already observed, remove it from the current position and push it to the end + nextDocs.splice(nextDocs.indexOf(nextDoc), 1) + nextDocs.push(nextDoc) + } else { + // Doc is not observed, push it to the end + nextDocs.push(nextDoc) + } + // Remove the oldest doc if we're observing more than the max allowed + if (nextDocs.length > MAX_OBSERVED_DOCUMENTS) { + nextDocs.shift() + } + return nextDocs + }, []), + map((ids) => ids.flatMap((id) => [getPublishedId(id), getDraftId(id)])), + switchMap((ids) => { + return observeDocuments(ids) + }), + startWith([]), + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false, + }), + ) + + return { + add: (id: string) => { + observedDocumentsSubject.next(id) + }, + visited$, + } +} diff --git a/packages/sanity/src/structure/panes/document/documentPanel/documentViews/FormView.tsx b/packages/sanity/src/structure/panes/document/documentPanel/documentViews/FormView.tsx index e4e63948b8b..f65cad7a2ec 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/documentViews/FormView.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/documentViews/FormView.tsx @@ -166,7 +166,7 @@ export const FormView = forwardRef(function FormV > - {connectionState === 'connecting' || !ready ? ( + {connectionState === 'connecting' && !editState?.draft && !editState?.published ? ( {/* TODO: replace with loading block */} @@ -205,7 +205,9 @@ export const FormView = forwardRef(function FormV onSetPathCollapsed={onSetCollapsedPath} openPath={openPath} presence={presence} - readOnly={connectionState === 'reconnecting' || formState.readOnly} + readOnly={ + connectionState === 'reconnecting' || formState.readOnly || !editState?.ready + } schemaType={formState.schemaType} validation={validation} value={ diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTabs.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTabs.tsx index 289d64f97ca..60534bcf789 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTabs.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTabs.tsx @@ -35,7 +35,7 @@ function DocumentHeaderTab(props: { viewId: string | null }) { const {icon, id, isActive, label, tabPanelId, viewId, ...rest} = props - const {ready} = useDocumentPane() + const {ready, editState} = useDocumentPane() const {setView} = usePaneRouter() const handleClick = useCallback(() => setView(viewId), [setView, viewId]) @@ -43,7 +43,7 @@ function DocumentHeaderTab(props: { keyboard navigation aria-controls={tabPanelId} - disabled={!ready} + disabled={!ready && !editState?.draft && !editState?.published} icon={icon} id={id} label={label} diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.tsx index 80b4e848d1f..ee6fe289438 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.tsx @@ -5,8 +5,9 @@ import {structureLocaleNamespace} from '../../../../i18n' import {useDocumentPane} from '../../useDocumentPane' export function DocumentHeaderTitle(): ReactElement { - const {connectionState, schemaType, title, value: documentValue} = useDocumentPane() - const subscribed = Boolean(documentValue) && connectionState !== 'connecting' + const {connectionState, schemaType, title, editState} = useDocumentPane() + const documentValue = editState?.draft || editState?.published + const subscribed = Boolean(documentValue) const {error, value} = useValuePreview({ enabled: subscribed, @@ -15,7 +16,7 @@ export function DocumentHeaderTitle(): ReactElement { }) const {t} = useTranslation(structureLocaleNamespace) - if (connectionState === 'connecting') { + if (connectionState === 'connecting' && !subscribed) { return <> } diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx index 3298537e451..ef37d951f93 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentPanelHeader.tsx @@ -137,7 +137,7 @@ export const DocumentPanelHeader = memo( } tabs={showTabs && } tabIndex={tabIndex} diff --git a/packages/sanity/src/structure/panes/document/useDocumentTitle.ts b/packages/sanity/src/structure/panes/document/useDocumentTitle.ts index b657a92cf1a..16c657bd198 100644 --- a/packages/sanity/src/structure/panes/document/useDocumentTitle.ts +++ b/packages/sanity/src/structure/panes/document/useDocumentTitle.ts @@ -22,8 +22,9 @@ interface UseDocumentTitle { * @returns The document title or error. See {@link UseDocumentTitle} */ export function useDocumentTitle(): UseDocumentTitle { - const {connectionState, schemaType, title, value: documentValue} = useDocumentPane() - const subscribed = Boolean(documentValue) && connectionState !== 'connecting' + const {connectionState, schemaType, title, editState} = useDocumentPane() + const documentValue = editState?.draft || editState?.published + const subscribed = Boolean(documentValue) const {error, value} = useValuePreview({ enabled: subscribed, @@ -31,7 +32,7 @@ export function useDocumentTitle(): UseDocumentTitle { value: documentValue, }) - if (connectionState === 'connecting') { + if (connectionState === 'connecting' && !subscribed) { return {error: undefined, title: undefined} } From 9e116d24c9fdd059677237bc6353058cde53feb8 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Wed, 25 Sep 2024 11:00:41 +0200 Subject: [PATCH 03/10] fix(structure): update documentHeaderTitle tests --- .../header/DocumentHeaderTitle.test.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.test.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.test.tsx index d34ec969a32..048bec4c3e8 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.test.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.test.tsx @@ -39,7 +39,7 @@ describe('DocumentHeaderTitle', () => { const defaultProps = { connectionState: 'connected', schemaType: {title: 'Test Schema', name: 'testSchema'}, - value: {title: 'Test Value'}, + editState: {draft: {title: 'Test Value'}}, } const defaultValue = { @@ -63,16 +63,33 @@ describe('DocumentHeaderTitle', () => { await waitFor(() => expect(getByText('Untitled')).toBeInTheDocument()) }) - it('should return an empty fragment when connectionState is not "connected"', async () => { + it('should return an empty fragment when connectionState is not "connected" and editState is empty', async () => { mockUseDocumentPane.mockReturnValue({ ...defaultProps, connectionState: 'connecting', + editState: null, } as unknown as DocumentPaneContextValue) const {container} = render() await waitFor(() => expect(container.firstChild).toBeNull()) }) + it('should render the header title when connectionState is not "connected" and editState has values', async () => { + mockUseDocumentPane.mockReturnValue({ + ...defaultProps, + connectionState: 'connecting', + } as unknown as DocumentPaneContextValue) + + mockUseValuePreview.mockReturnValue({ + ...defaultValue, + error: undefined, + value: {title: 'Test Value'}, + }) + + const {getByText} = render() + await waitFor(() => expect(getByText('Test Value')).toBeInTheDocument()) + }) + it('should return the title if it is provided', async () => { mockUseDocumentPane.mockReturnValue({ ...defaultProps, @@ -89,7 +106,7 @@ describe('DocumentHeaderTitle', () => { it('should return "New {schemaType?.title || schemaType?.name}" if documentValue is not provided', async () => { mockUseDocumentPane.mockReturnValue({ ...defaultProps, - value: null, + editState: null, } as unknown as DocumentPaneContextValue) const client = createMockSanityClient() @@ -146,7 +163,7 @@ describe('DocumentHeaderTitle', () => { expect(mockUseValuePreview).toHaveBeenCalledWith({ enabled: true, schemaType: defaultProps.schemaType, - value: defaultProps.value, + value: defaultProps.editState.draft, }), ) }) From 47752f6cd2e7470766421073967c2ca026b2af63 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 26 Sep 2024 07:19:50 +0200 Subject: [PATCH 04/10] Revert "feat(peview): add `unstable_observeDocument(s)` to preview store" This reverts commit 72a05a75d136d74a2650e2434c4ca646fa69a6f3. --- .../src/core/preview/createGlobalListener.ts | 1 - .../src/core/preview/createObserveDocument.ts | 87 ------------------- .../src/core/preview/documentPreviewStore.ts | 20 +---- .../core/preview/utils/applyMendozaPatch.ts | 18 ---- 4 files changed, 1 insertion(+), 125 deletions(-) delete mode 100644 packages/sanity/src/core/preview/createObserveDocument.ts delete mode 100644 packages/sanity/src/core/preview/utils/applyMendozaPatch.ts diff --git a/packages/sanity/src/core/preview/createGlobalListener.ts b/packages/sanity/src/core/preview/createGlobalListener.ts index 7be43f93e31..ac76417b386 100644 --- a/packages/sanity/src/core/preview/createGlobalListener.ts +++ b/packages/sanity/src/core/preview/createGlobalListener.ts @@ -19,7 +19,6 @@ export function createGlobalListener(client: SanityClient) { includePreviousRevision: false, includeMutations: false, visibility: 'query', - effectFormat: 'mendoza', tag: 'preview.global', }, ) diff --git a/packages/sanity/src/core/preview/createObserveDocument.ts b/packages/sanity/src/core/preview/createObserveDocument.ts deleted file mode 100644 index 6b384a7fbf8..00000000000 --- a/packages/sanity/src/core/preview/createObserveDocument.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' -import {type SanityDocument} from '@sanity/types' -import {memoize, uniq} from 'lodash' -import {EMPTY, finalize, type Observable, of} from 'rxjs' -import {concatMap, map, scan, shareReplay} from 'rxjs/operators' - -import {type ApiConfig} from './types' -import {applyMendozaPatch} from './utils/applyMendozaPatch' -import {debounceCollect} from './utils/debounceCollect' - -export function createObserveDocument({ - mutationChannel, - client, -}: { - client: SanityClient - mutationChannel: Observable -}) { - const getBatchFetcher = memoize( - function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) { - const _client = client.withConfig(apiConfig) - - function batchFetchDocuments(ids: [string][]) { - return _client.observable - .fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'}) - .pipe( - // eslint-disable-next-line max-nested-callbacks - map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))), - ) - } - return debounceCollect(batchFetchDocuments, 100) - }, - (apiConfig) => apiConfig.dataset + apiConfig.projectId, - ) - - const MEMO: Record> = {} - - function observeDocument(id: string, apiConfig?: ApiConfig) { - const _apiConfig = apiConfig || { - dataset: client.config().dataset!, - projectId: client.config().projectId!, - } - const fetchDocument = getBatchFetcher(_apiConfig) - return mutationChannel.pipe( - concatMap((event) => { - if (event.type === 'welcome') { - return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document}))) - } - return event.documentId === id ? of(event) : EMPTY - }), - scan((current: SanityDocument | undefined, event) => { - if (event.type === 'sync') { - return event.document - } - if (event.type === 'mutation') { - return applyMutationEvent(current, event) - } - //@ts-expect-error - this should never happen - throw new Error(`Unexpected event type: "${event.type}"`) - }, undefined), - ) - } - return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) { - const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id - if (!(key in MEMO)) { - MEMO[key] = observeDocument(id, apiConfig).pipe( - finalize(() => delete MEMO[key]), - shareReplay({bufferSize: 1, refCount: true}), - ) - } - return MEMO[key] - } -} - -function applyMutationEvent(current: SanityDocument | undefined, event: MutationEvent) { - if (event.previousRev !== current?._rev) { - console.warn('Document out of sync, skipping mutation') - return current - } - if (!event.effects) { - throw new Error( - 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', - ) - } - const next = applyMendozaPatch(current, event.effects.apply) - // next will be undefined in case of deletion - return next ? {...next, _rev: event.resultRev} : undefined -} diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index fbedd6b7429..154c7e56c26 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,12 +1,11 @@ import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' import {type PrepareViewOptions, type SanityDocument} from '@sanity/types' -import {combineLatest, type Observable} from 'rxjs' +import {type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' import {isRecord} from '../util' import {createPreviewAvailabilityObserver} from './availability' import {createGlobalListener} from './createGlobalListener' -import {createObserveDocument} from './createObserveDocument' import {createPathObserver} from './createPathObserver' import {createPreviewObserver} from './createPreviewObserver' import {createObservePathsDocumentPair} from './documentPair' @@ -57,18 +56,6 @@ export interface DocumentPreviewStore { id: string, paths: PreviewPath[], ) => Observable> - /** - * Observe a complete document with the given ID - * @hidden - * @beta - */ - unstable_observeDocument: (id: string) => Observable - /** - * Observe a list of complete documents with the given IDs - * @hidden - * @beta - */ - unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]> } /** @internal */ @@ -92,8 +79,6 @@ export function createDocumentPreviewStore({ map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)), ) - const observeDocument = createObserveDocument({client, mutationChannel: globalListener}) - const observeFields = createObserveFields({client: versionedClient, invalidationChannel}) const observePaths = createPathObserver({observeFields}) @@ -125,9 +110,6 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, - unstable_observeDocument: observeDocument, - unstable_observeDocuments: (ids: string[]) => - combineLatest(ids.map((id) => observeDocument(id))), unstable_observeDocumentPairAvailability: observeDocumentPairAvailability, unstable_observePathsDocumentPair: observePathsDocumentPair, } diff --git a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts deleted file mode 100644 index 0c1be69450c..00000000000 --- a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {type SanityDocument} from '@sanity/types' -import {applyPatch, type RawPatch} from 'mendoza' - -function omitRev(document: SanityDocument | undefined) { - if (document === undefined) { - return undefined - } - const {_rev, ...doc} = document - return doc -} - -export function applyMendozaPatch( - document: SanityDocument | undefined, - patch: RawPatch, -): SanityDocument | undefined { - const next = applyPatch(omitRev(document), patch) - return next === null ? undefined : next -} From e486182f2b093714439647734bed8bd0a7cc3093 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 26 Sep 2024 07:43:57 +0200 Subject: [PATCH 05/10] chore(core): remove getVisitedDocuments --- .../document/getVisitedDocuments.test.ts | 211 ------------------ .../_legacy/document/getVisitedDocuments.ts | 60 ----- 2 files changed, 271 deletions(-) delete mode 100644 packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts delete mode 100644 packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts diff --git a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts deleted file mode 100644 index 2479434551c..00000000000 --- a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import {beforeEach, describe, expect, it, jest} from '@jest/globals' -import {type SanityDocument} from '@sanity/types' -import {of} from 'rxjs' - -import {getVisitedDocuments} from './getVisitedDocuments' - -const mockObserveDocuments = jest.fn((ids: string[]) => { - // Return an observable that emits an array of documents corresponding to the IDs - return of( - ids.map( - (id) => - ({ - _id: id, - _type: 'foo', - }) as unknown as SanityDocument, - ), - ) -}) - -type Emissions = (SanityDocument | undefined)[][] - -describe('getVisitedDocuments', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should start with an empty array', () => { - const {visited$} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - expect(emissions).toHaveLength(1) - expect(emissions[0]).toEqual([]) - }) - - it('should emit documents when an ID is added', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - add('doc1') - - expect(emissions).toHaveLength(2) - - const expectedDocs = [ - {_id: 'doc1', _type: 'foo'}, - {_id: 'drafts.doc1', _type: 'foo'}, - ] - - expect(emissions[1]).toEqual(expectedDocs) - }) - - it('should emit documents when multiple IDs are added', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - add('doc1') - add('doc2') - - expect(emissions).toHaveLength(3) - - const expectedDocs = [ - {_id: 'doc1', _type: 'foo'}, - {_id: 'drafts.doc1', _type: 'foo'}, - {_id: 'doc2', _type: 'foo'}, - {_id: 'drafts.doc2', _type: 'foo'}, - ] - - expect(emissions[2]).toEqual(expectedDocs) - }) - - it('should move an existing ID to the end when re-added', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - add('doc1') - add('doc2') - add('doc3') - add('doc2') // Re-add 'doc2' - - // Expected IDs after re-adding 'doc2': ['doc1', 'doc3', 'doc2'] - const expectedDocs = [ - {_id: 'doc1', _type: 'foo'}, - {_id: 'drafts.doc1', _type: 'foo'}, - {_id: 'doc3', _type: 'foo'}, - {_id: 'drafts.doc3', _type: 'foo'}, - {_id: 'doc2', _type: 'foo'}, - {_id: 'drafts.doc2', _type: 'foo'}, - ] - - expect(emissions[emissions.length - 1]).toEqual(expectedDocs) - }) - - it('should not duplicate documents when the same ID is added multiple times', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - add('doc1') - add('doc1') - add('doc1') - - expect(emissions).toHaveLength(4) - - const expectedDocs = [ - {_id: 'doc1', _type: 'foo'}, - {_id: 'drafts.doc1', _type: 'foo'}, - ] - - expect(emissions[emissions.length - 1]).toEqual(expectedDocs) - }) - - it('should maintain up to MAX_OBSERVED_DOCUMENTS IDs', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissions: Emissions = [] - const docIds = ['doc1', 'doc2', 'doc3', 'doc4', 'doc5', 'doc6'] - - visited$.subscribe((docs) => { - emissions.push(docs) - }) - - docIds.forEach((id) => add(id)) - - expect(mockObserveDocuments).toHaveBeenLastCalledWith([ - 'doc2', - 'drafts.doc2', - 'doc3', - 'drafts.doc3', - 'doc4', - 'drafts.doc4', - 'doc5', - 'drafts.doc5', - 'doc6', - 'drafts.doc6', - ]) - - // The last emission should only contain the last 5 documents (doc2 to doc6) in the correct order, removes the oldest doc (doc1) - const expectedDocs = [ - {_id: 'doc2', _type: 'foo'}, - {_id: 'drafts.doc2', _type: 'foo'}, - {_id: 'doc3', _type: 'foo'}, - {_id: 'drafts.doc3', _type: 'foo'}, - {_id: 'doc4', _type: 'foo'}, - {_id: 'drafts.doc4', _type: 'foo'}, - {_id: 'doc5', _type: 'foo'}, - {_id: 'drafts.doc5', _type: 'foo'}, - {_id: 'doc6', _type: 'foo'}, - {_id: 'drafts.doc6', _type: 'foo'}, - ] - - expect(emissions[emissions.length - 1]).toEqual(expectedDocs) - }) - - it('should keep the observer alive even when no one is subscribed', () => { - const {visited$, add} = getVisitedDocuments({observeDocuments: mockObserveDocuments}) - - const emissionsFirstSub: Emissions = [] - const emissionsSecondSub: Emissions = [] - - const subscription = visited$.subscribe((docs) => { - emissionsFirstSub.push(docs) - }) - - add('doc1') - add('doc2') - - expect(emissionsFirstSub).toHaveLength(3) - - const expectedDocs = [ - {_id: 'doc1', _type: 'foo'}, - {_id: 'drafts.doc1', _type: 'foo'}, - {_id: 'doc2', _type: 'foo'}, - {_id: 'drafts.doc2', _type: 'foo'}, - ] - - expect(emissionsFirstSub[emissionsFirstSub.length - 1]).toEqual(expectedDocs) - - // unsubscribe - subscription.unsubscribe() - - visited$.subscribe((docs) => { - emissionsSecondSub.push(docs) - }) - // Should have the last emitted documents - expect(emissionsSecondSub).toHaveLength(1) - expect(emissionsSecondSub[emissionsSecondSub.length - 1]).toEqual(expectedDocs) - }) -}) diff --git a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts b/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts deleted file mode 100644 index 146cb9142c9..00000000000 --- a/packages/sanity/src/core/store/_legacy/document/getVisitedDocuments.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {type SanityDocument} from '@sanity/types' -import {type Observable, ReplaySubject, Subject} from 'rxjs' -import {map, scan, share, startWith, switchMap} from 'rxjs/operators' - -import {type DocumentPreviewStore} from '../../../preview' -import {getDraftId, getPublishedId} from '../../../util' - -const MAX_OBSERVED_DOCUMENTS = 5 - -/** - * Keeps a listener of the documents the user visited through the form. - * Allowing us to provide a quick way to navigate back to the last visited documents. - */ -export function getVisitedDocuments({ - observeDocuments, -}: { - observeDocuments: DocumentPreviewStore['unstable_observeDocuments'] -}): { - add: (id: string) => void - visited$: Observable<(SanityDocument | undefined)[]> -} { - const observedDocumentsSubject = new Subject() - - const visited$ = observedDocumentsSubject.pipe( - scan((prev: string[], nextDoc: string) => { - const nextDocs = [...prev] - if (nextDocs.includes(nextDoc)) { - // Doc is already observed, remove it from the current position and push it to the end - nextDocs.splice(nextDocs.indexOf(nextDoc), 1) - nextDocs.push(nextDoc) - } else { - // Doc is not observed, push it to the end - nextDocs.push(nextDoc) - } - // Remove the oldest doc if we're observing more than the max allowed - if (nextDocs.length > MAX_OBSERVED_DOCUMENTS) { - nextDocs.shift() - } - return nextDocs - }, []), - map((ids) => ids.flatMap((id) => [getPublishedId(id), getDraftId(id)])), - switchMap((ids) => { - return observeDocuments(ids) - }), - startWith([]), - share({ - connector: () => new ReplaySubject(1), - resetOnError: false, - resetOnComplete: false, - resetOnRefCountZero: false, - }), - ) - - return { - add: (id: string) => { - observedDocumentsSubject.next(id) - }, - visited$, - } -} From 432b403758764f7edb16c1dcbec745dfc4a69274 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 26 Sep 2024 08:01:41 +0200 Subject: [PATCH 06/10] chore(core): add document store in local storage POC --- .../document/document-pair/editState.ts | 11 ++- .../document-pair/utils/localStoragePOC.ts | 76 +++++++++++++++++++ .../document/document-pair/validation.ts | 4 +- .../store/_legacy/document/document-store.ts | 18 ++--- 4 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index 36c9656c297..8d753dd7ddc 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,14 +1,14 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' import {combineLatest, type Observable} from 'rxjs' -import {map, publishReplay, refCount, startWith, switchMap, take} from 'rxjs/operators' +import {finalize, map, publishReplay, refCount, startWith, switchMap, tap} from 'rxjs/operators' import {type PairListenerOptions} from '../getPairListener' import {type IdPair, type PendingMutationsEvent} from '../types' import {memoize} from '../utils/createMemoizer' -import {memoizeKeyGen} from './memoizeKeyGen' import {snapshotPair} from './snapshotPair' import {isLiveEditEnabled} from './utils/isLiveEditEnabled' +import {savePairToLocalStorage} from './utils/localStoragePOC' interface TransactionSyncLockState { enabled: boolean @@ -40,7 +40,7 @@ export const editState = memoize( }, idPair: IdPair, typeName: string, - visited$: Observable<(SanityDocument | undefined)[]>, + localStoragePair: {draft: SanityDocument | null; published: SanityDocument | null} | undefined, ): Observable => { const liveEdit = isLiveEditEnabled(ctx.schema, typeName) return snapshotPair( @@ -82,5 +82,8 @@ export const editState = memoize( refCount(), ) }, - (ctx, idPair, typeName) => memoizeKeyGen(ctx.client, idPair, typeName), + (ctx, idPair, typeName, lsPair) => { + const config = ctx.client.config() + return `${config.dataset ?? ''}-${config.projectId ?? ''}-${idPair.publishedId}-${typeName}-${lsPair?.draft?._rev}-${lsPair?.published?._rev}` + }, ) diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts new file mode 100644 index 00000000000..aebda4781b5 --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts @@ -0,0 +1,76 @@ +import {isSanityDocument, type SanityDocument} from '@sanity/types' + +import {supportsLocalStorage} from '../../../../../util/supportsLocalStorage' +import {type IdPair} from '../../types' + +const createDocumentLocalStorageKey = (documentId: string) => `sanity:editState:${documentId}` + +const getDocumentFromLocalStorage = (id: string): SanityDocument | null => { + if (!supportsLocalStorage) return null + + const key = createDocumentLocalStorageKey(id) + + try { + const document = localStorage.getItem(key) + + if (!document) return null + const parsed = JSON.parse(document) + return isSanityDocument(parsed) ? parsed : null + } catch (error) { + console.error(`Error parsing document with ID ${id} from localStorage:`, error) + return null + } +} + +const saveDocumentToLocalStorage = (document: SanityDocument) => { + if (!supportsLocalStorage) return + + const key = createDocumentLocalStorageKey(document._id) + + try { + localStorage.setItem(key, JSON.stringify(document)) + } catch (error) { + console.error(`Error saving document with ID ${document._id} to localStorage:`, error) + } +} + +/** + * Function to get the draft and published document from local storage + * it's not production ready, it's POC only, local storage supports up to 5mb of data which won't be enough for the datasets. + * @internal + * @hidden + */ +export const getPairFromLocalStorage = (idPair: IdPair) => { + if (!supportsLocalStorage) { + return { + draft: null, + published: null, + } + } + + return { + draft: getDocumentFromLocalStorage(idPair.draftId), + published: getDocumentFromLocalStorage(idPair.publishedId), + } +} + +/** + * Function to save the draft and published documents to local storage. + * Note: This is a proof of concept and not production-ready. + * Local storage supports up to 5MB of data, which will not be sufficient for large datasets. + * @internal + * @hidden + */ +export const savePairToLocalStorage = ( + documentPair: { + draft: SanityDocument | null + published: SanityDocument | null + } | null, +) => { + if (documentPair?.draft) { + saveDocumentToLocalStorage(documentPair.draft) + } + if (documentPair?.published) { + saveDocumentToLocalStorage(documentPair.published) + } +} diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts index ca1933a0598..b6615ae0c11 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts @@ -36,9 +36,9 @@ export const validation = memoize( }, {draftId, publishedId}: IdPair, typeName: string, - visited$: Observable<(SanityDocument | undefined)[]>, + localStoragePair: {draft: SanityDocument | null; published: SanityDocument | null}, ): Observable => { - const document$ = editState(ctx, {draftId, publishedId}, typeName, visited$).pipe( + const document$ = editState(ctx, {draftId, publishedId}, typeName, localStoragePair).pipe( map(({draft, published}) => draft || published), throttleTime(DOC_UPDATE_DELAY, asyncScheduler, {trailing: true}), distinctUntilChanged((prev, next) => { diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index e1d4d584696..6100b4d7c64 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -22,6 +22,7 @@ import { type OperationSuccess, } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' +import {getPairFromLocalStorage} from './document-pair/utils/localStoragePOC' import {validation} from './document-pair/validation' import {getVisitedDocuments} from './getVisitedDocuments' import {type PairListenerOptions} from './getPairListener' @@ -104,10 +105,6 @@ export function createDocumentStore({ const observeDocumentPairAvailability = documentPreviewStore.unstable_observeDocumentPairAvailability - const visitedDocuments = getVisitedDocuments({ - observeDocuments: documentPreviewStore.unstable_observeDocuments, - }) - // Note that we're both passing a shared `client` here which is used by the // internal operations, and a `getClient` method that we expose to user-land // for things like validations @@ -167,13 +164,9 @@ export function createDocumentStore({ return editOperations(ctx, getIdPairFromPublished(publishedId), type) }, editState(publishedId, type) { - const edit = editState( - ctx, - getIdPairFromPublished(publishedId), - type, - visitedDocuments.visited$, - ) - visitedDocuments.add(publishedId) + const idPair = getIdPairFromPublished(publishedId) + + const edit = editState(ctx, idPair, type, getPairFromLocalStorage(idPair)) return edit }, operationEvents(publishedId, type) { @@ -197,7 +190,8 @@ export function createDocumentStore({ ) }, validation(publishedId, type) { - return validation(ctx, getIdPairFromPublished(publishedId), type, visitedDocuments.visited$) + const idPair = getIdPairFromPublished(publishedId) + return validation(ctx, idPair, type, getPairFromLocalStorage(idPair)) }, }, } From 371ecfba546811325ae8eef72d453222aa725e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rge=20N=C3=A6ss?= Date: Thu, 26 Sep 2024 10:59:45 +0200 Subject: [PATCH 07/10] fix(sanity): lazy read cached value from either memory or local store --- .../store/_legacy/document/document-pair/editState.ts | 11 ++++------- .../_legacy/document/document-pair/validation.ts | 5 ++--- .../src/core/store/_legacy/document/document-store.ts | 5 ++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index 8d753dd7ddc..81d1ef267ad 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,14 +1,15 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' -import {combineLatest, type Observable} from 'rxjs' +import {combineLatest, defer, merge, type Observable, of} from 'rxjs' import {finalize, map, publishReplay, refCount, startWith, switchMap, tap} from 'rxjs/operators' import {type PairListenerOptions} from '../getPairListener' import {type IdPair, type PendingMutationsEvent} from '../types' import {memoize} from '../utils/createMemoizer' +import {memoizeKeyGen} from './memoizeKeyGen' import {snapshotPair} from './snapshotPair' import {isLiveEditEnabled} from './utils/isLiveEditEnabled' -import {savePairToLocalStorage} from './utils/localStoragePOC' +import {getPairFromLocalStorage, savePairToLocalStorage} from './utils/localStoragePOC' interface TransactionSyncLockState { enabled: boolean @@ -40,7 +41,6 @@ export const editState = memoize( }, idPair: IdPair, typeName: string, - localStoragePair: {draft: SanityDocument | null; published: SanityDocument | null} | undefined, ): Observable => { const liveEdit = isLiveEditEnabled(ctx.schema, typeName) return snapshotPair( @@ -82,8 +82,5 @@ export const editState = memoize( refCount(), ) }, - (ctx, idPair, typeName, lsPair) => { - const config = ctx.client.config() - return `${config.dataset ?? ''}-${config.projectId ?? ''}-${idPair.publishedId}-${typeName}-${lsPair?.draft?._rev}-${lsPair?.published?._rev}` - }, + (ctx, idPair, typeName) => memoizeKeyGen(ctx.client, idPair, typeName), ) diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts index b6615ae0c11..084ad22cece 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/validation.ts @@ -1,5 +1,5 @@ import {type SanityClient} from '@sanity/client' -import {type SanityDocument, type Schema} from '@sanity/types' +import {type Schema} from '@sanity/types' import {omit} from 'lodash' import {asyncScheduler, type Observable} from 'rxjs' import {distinctUntilChanged, map, shareReplay, throttleTime} from 'rxjs/operators' @@ -36,9 +36,8 @@ export const validation = memoize( }, {draftId, publishedId}: IdPair, typeName: string, - localStoragePair: {draft: SanityDocument | null; published: SanityDocument | null}, ): Observable => { - const document$ = editState(ctx, {draftId, publishedId}, typeName, localStoragePair).pipe( + const document$ = editState(ctx, {draftId, publishedId}, typeName).pipe( map(({draft, published}) => draft || published), throttleTime(DOC_UPDATE_DELAY, asyncScheduler, {trailing: true}), distinctUntilChanged((prev, next) => { diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 6100b4d7c64..5cddfb9f1c1 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -22,7 +22,6 @@ import { type OperationSuccess, } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' -import {getPairFromLocalStorage} from './document-pair/utils/localStoragePOC' import {validation} from './document-pair/validation' import {getVisitedDocuments} from './getVisitedDocuments' import {type PairListenerOptions} from './getPairListener' @@ -166,7 +165,7 @@ export function createDocumentStore({ editState(publishedId, type) { const idPair = getIdPairFromPublished(publishedId) - const edit = editState(ctx, idPair, type, getPairFromLocalStorage(idPair)) + const edit = editState(ctx, idPair, type) return edit }, operationEvents(publishedId, type) { @@ -191,7 +190,7 @@ export function createDocumentStore({ }, validation(publishedId, type) { const idPair = getIdPairFromPublished(publishedId) - return validation(ctx, idPair, type, getPairFromLocalStorage(idPair)) + return validation(ctx, idPair, type) }, }, } From 15eba8e74ee8d7d2b043eb34865bfeea8c9492de Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Fri, 27 Sep 2024 11:20:16 +0200 Subject: [PATCH 08/10] feat(core): use quick-lru for documents edit state --- .../document/document-pair/editState.ts | 6 +- .../document-pair/utils/localStoragePOC.ts | 76 ------------------- .../store/_legacy/document/document-store.ts | 5 +- .../_legacy/document/documentsStorage.ts | 21 +++++ 4 files changed, 27 insertions(+), 81 deletions(-) delete mode 100644 packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts create mode 100644 packages/sanity/src/core/store/_legacy/document/documentsStorage.ts diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index 81d1ef267ad..3affaf92b48 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,15 +1,15 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' -import {combineLatest, defer, merge, type Observable, of} from 'rxjs' -import {finalize, map, publishReplay, refCount, startWith, switchMap, tap} from 'rxjs/operators' +import {combineLatest, defer, type Observable, of} from 'rxjs' +import {map, publishReplay, refCount, startWith, switchMap, tap} from 'rxjs/operators' import {type PairListenerOptions} from '../getPairListener' +import {type DocumentsStorage} from '../documentsStorage' import {type IdPair, type PendingMutationsEvent} from '../types' import {memoize} from '../utils/createMemoizer' import {memoizeKeyGen} from './memoizeKeyGen' import {snapshotPair} from './snapshotPair' import {isLiveEditEnabled} from './utils/isLiveEditEnabled' -import {getPairFromLocalStorage, savePairToLocalStorage} from './utils/localStoragePOC' interface TransactionSyncLockState { enabled: boolean diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts deleted file mode 100644 index aebda4781b5..00000000000 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/utils/localStoragePOC.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {isSanityDocument, type SanityDocument} from '@sanity/types' - -import {supportsLocalStorage} from '../../../../../util/supportsLocalStorage' -import {type IdPair} from '../../types' - -const createDocumentLocalStorageKey = (documentId: string) => `sanity:editState:${documentId}` - -const getDocumentFromLocalStorage = (id: string): SanityDocument | null => { - if (!supportsLocalStorage) return null - - const key = createDocumentLocalStorageKey(id) - - try { - const document = localStorage.getItem(key) - - if (!document) return null - const parsed = JSON.parse(document) - return isSanityDocument(parsed) ? parsed : null - } catch (error) { - console.error(`Error parsing document with ID ${id} from localStorage:`, error) - return null - } -} - -const saveDocumentToLocalStorage = (document: SanityDocument) => { - if (!supportsLocalStorage) return - - const key = createDocumentLocalStorageKey(document._id) - - try { - localStorage.setItem(key, JSON.stringify(document)) - } catch (error) { - console.error(`Error saving document with ID ${document._id} to localStorage:`, error) - } -} - -/** - * Function to get the draft and published document from local storage - * it's not production ready, it's POC only, local storage supports up to 5mb of data which won't be enough for the datasets. - * @internal - * @hidden - */ -export const getPairFromLocalStorage = (idPair: IdPair) => { - if (!supportsLocalStorage) { - return { - draft: null, - published: null, - } - } - - return { - draft: getDocumentFromLocalStorage(idPair.draftId), - published: getDocumentFromLocalStorage(idPair.publishedId), - } -} - -/** - * Function to save the draft and published documents to local storage. - * Note: This is a proof of concept and not production-ready. - * Local storage supports up to 5MB of data, which will not be sufficient for large datasets. - * @internal - * @hidden - */ -export const savePairToLocalStorage = ( - documentPair: { - draft: SanityDocument | null - published: SanityDocument | null - } | null, -) => { - if (documentPair?.draft) { - saveDocumentToLocalStorage(documentPair.draft) - } - if (documentPair?.published) { - saveDocumentToLocalStorage(documentPair.published) - } -} diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 5cddfb9f1c1..977960afe9c 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -25,11 +25,11 @@ import {type OperationsAPI} from './document-pair/operations' import {validation} from './document-pair/validation' import {getVisitedDocuments} from './getVisitedDocuments' import {type PairListenerOptions} from './getPairListener' +import {createDocumentsStorage} from './documentsStorage' import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue' import {listenQuery, type ListenQueryOptions} from './listenQuery' import {resolveTypeForDocument} from './resolveTypeForDocument' import {type IdPair} from './types' - /** * @hidden * @beta */ @@ -108,7 +108,7 @@ export function createDocumentStore({ // internal operations, and a `getClient` method that we expose to user-land // for things like validations const client = getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) - + const storage = createDocumentsStorage() const ctx = { client, getClient, @@ -118,6 +118,7 @@ export function createDocumentStore({ i18n, serverActionsEnabled, pairListenerOptions, + storage, } return { diff --git a/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts b/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts new file mode 100644 index 00000000000..5da6aa16c74 --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts @@ -0,0 +1,21 @@ +import {type SanityDocument} from '@sanity/types' +import QuickLRU from 'quick-lru' + +export interface DocumentsStorage { + save: (id: string, doc: SanityDocument) => void + get: (id: string) => SanityDocument | null +} + +export function createDocumentsStorage(): DocumentsStorage { + const documentsCache = new QuickLRU({ + maxSize: 50, + }) + return { + save(id, doc) { + documentsCache.set(id, doc) + }, + get(id) { + return documentsCache.get(id) || null + }, + } +} From dba471cf8bbeaed338a6b4af3aeb31d655461863 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Tue, 1 Oct 2024 08:44:57 +0200 Subject: [PATCH 09/10] chore(core): update editState to use createSWR cache --- .../document/document-pair/editState.ts | 16 ++++++++------ .../store/_legacy/document/document-store.ts | 4 ++-- .../_legacy/document/documentsStorage.ts | 21 ------------------- 3 files changed, 12 insertions(+), 29 deletions(-) delete mode 100644 packages/sanity/src/core/store/_legacy/document/documentsStorage.ts diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index 3affaf92b48..6565d4fac96 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,10 +1,10 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' -import {combineLatest, defer, type Observable, of} from 'rxjs' -import {map, publishReplay, refCount, startWith, switchMap, tap} from 'rxjs/operators' +import {combineLatest, type Observable} from 'rxjs' +import {map, publishReplay, refCount, startWith, switchMap} from 'rxjs/operators' +import {createSWR} from '../../../../util/rxSwr' import {type PairListenerOptions} from '../getPairListener' -import {type DocumentsStorage} from '../documentsStorage' import {type IdPair, type PendingMutationsEvent} from '../types' import {memoize} from '../utils/createMemoizer' import {memoizeKeyGen} from './memoizeKeyGen' @@ -15,6 +15,8 @@ interface TransactionSyncLockState { enabled: boolean } +const swr = createSWR<[SanityDocument, SanityDocument, TransactionSyncLockState]>({maxSize: 50}) + /** * @hidden * @beta */ @@ -43,6 +45,7 @@ export const editState = memoize( typeName: string, ): Observable => { const liveEdit = isLiveEditEnabled(ctx.schema, typeName) + return snapshotPair( ctx.client, idPair, @@ -60,14 +63,15 @@ export const editState = memoize( ), ]), ), - map(([draftSnapshot, publishedSnapshot, transactionSyncLock]) => ({ + swr(`${idPair.publishedId}-${idPair.draftId}`), + map(({value: [draftSnapshot, publishedSnapshot, transactionSyncLock], fromCache}) => ({ id: idPair.publishedId, type: typeName, draft: draftSnapshot, published: publishedSnapshot, liveEdit, - ready: true, - transactionSyncLock, + ready: !fromCache, + transactionSyncLock: fromCache ? null : transactionSyncLock, })), startWith({ id: idPair.publishedId, diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 977960afe9c..813962d3fdc 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -30,6 +30,7 @@ import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} f import {listenQuery, type ListenQueryOptions} from './listenQuery' import {resolveTypeForDocument} from './resolveTypeForDocument' import {type IdPair} from './types' + /** * @hidden * @beta */ @@ -108,7 +109,7 @@ export function createDocumentStore({ // internal operations, and a `getClient` method that we expose to user-land // for things like validations const client = getClient(DEFAULT_STUDIO_CLIENT_OPTIONS) - const storage = createDocumentsStorage() + const ctx = { client, getClient, @@ -118,7 +119,6 @@ export function createDocumentStore({ i18n, serverActionsEnabled, pairListenerOptions, - storage, } return { diff --git a/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts b/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts deleted file mode 100644 index 5da6aa16c74..00000000000 --- a/packages/sanity/src/core/store/_legacy/document/documentsStorage.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {type SanityDocument} from '@sanity/types' -import QuickLRU from 'quick-lru' - -export interface DocumentsStorage { - save: (id: string, doc: SanityDocument) => void - get: (id: string) => SanityDocument | null -} - -export function createDocumentsStorage(): DocumentsStorage { - const documentsCache = new QuickLRU({ - maxSize: 50, - }) - return { - save(id, doc) { - documentsCache.set(id, doc) - }, - get(id) { - return documentsCache.get(id) || null - }, - } -} From 0a6e398e1a3fae1cf30726bc5467343d4bf85860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rge=20N=C3=A6ss?= Date: Tue, 8 Oct 2024 17:03:52 +0200 Subject: [PATCH 10/10] chore(core) type errors after rebase --- .../sanity/src/core/store/_legacy/document/document-store.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 813962d3fdc..8aeec78a0d8 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -23,9 +23,7 @@ import { } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' import {validation} from './document-pair/validation' -import {getVisitedDocuments} from './getVisitedDocuments' import {type PairListenerOptions} from './getPairListener' -import {createDocumentsStorage} from './documentsStorage' import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue' import {listenQuery, type ListenQueryOptions} from './listenQuery' import {resolveTypeForDocument} from './resolveTypeForDocument'