sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
446 lines (403 loc) • 14 kB
text/typescript
import {generateHelpUrl} from '@sanity/generate-help-url'
import {isEqual} from 'lodash'
import {concat, NEVER, type Observable, of as observableOf} from 'rxjs'
import {distinctUntilChanged, map, pairwise, scan, startWith, switchMap} from 'rxjs/operators'
import {type StructureContext} from '../structureBuilder'
import {
type DocumentPaneNode,
type PaneNode,
type PaneNodeResolver,
type RouterPanes,
type RouterPaneSibling,
type RouterPaneSiblingContext,
type UnresolvedPaneNode,
} from '../types'
import {assignId} from './assignId'
import {
createPaneResolver,
type PaneResolver,
type PaneResolverMiddleware,
} from './createPaneResolver'
import {memoBind} from './memoBind'
import {PaneResolutionError} from './PaneResolutionError'
/**
* the fallback editor child that is implicitly inserted into the structure tree
* if the id starts with `__edit__`
*/
const fallbackEditorChild: PaneNodeResolver = (nodeId, context): DocumentPaneNode => {
const id = nodeId.replace(/^__edit__/, '')
const {
params,
payload,
structureContext: {resolveDocumentNode},
} = context
const {type, template} = params
if (!type) {
throw new Error(
`Document type for document with ID ${id} was not provided in the router params.`,
)
}
let defaultDocumentBuilder = resolveDocumentNode({schemaType: type, documentId: id}).id('editor')
if (template) {
defaultDocumentBuilder = defaultDocumentBuilder.initialValueTemplate(
template,
payload as {[key: string]: unknown},
)
}
return defaultDocumentBuilder.serialize() as DocumentPaneNode
}
/**
* takes in a `RouterPaneSiblingContext` and returns a normalized string
* representation that can be used for comparisons
*/
function hashContext(context: RouterPaneSiblingContext): string {
return `contextHash(${JSON.stringify({
id: context.id,
parentId: parent && assignId(parent),
path: context.path,
index: context.index,
splitIndex: context.splitIndex,
serializeOptionsIndex: context.serializeOptions?.index,
serializeOptionsPath: context.serializeOptions?.path,
})})`
}
/**
* takes in `ResolvedPaneMeta` and returns a normalized string representation
* that can be used for comparisons
*/
const hashResolvedPaneMeta = (meta: ResolvedPaneMeta): string => {
const normalized = {
type: meta.type,
id: meta.routerPaneSibling.id,
params: meta.routerPaneSibling.params || {},
payload: meta.routerPaneSibling.payload || null,
flatIndex: meta.flatIndex,
groupIndex: meta.groupIndex,
siblingIndex: meta.siblingIndex,
path: meta.path,
paneNode: meta.type === 'resolvedMeta' ? assignId(meta.paneNode) : null,
}
return `metaHash(${JSON.stringify(normalized)})`
}
/**
* Represents one flattened "router pane", including the source group and
* sibling indexes.
*
* @see RouterPanes
*/
interface FlattenedRouterPane {
routerPaneSibling: RouterPaneSibling
flatIndex: number
groupIndex: number
siblingIndex: number
}
/**
* The state of the accumulator used to store and manage memo cache state
*/
interface CacheState {
/**
* Holds the memoization results keyed by a combination of `assignId` and a
* context hash.
*/
resolvedPaneCache: Map<string, Observable<PaneNode>>
/**
* Acts as a dictionary that stores cache keys by their flat index. This is
* used to clean up the cache between different branches in the pane
* structure.
*
* @see createResolvedPaneNodeStream look inside the `scan` where `wrapFn` is
* defined
*/
cacheKeysByFlatIndex: Array<Set<string>>
/**
* The resulting memoized `PaneResolver` function. This function closes over
* the `resolvedPaneCache`.
*/
resolvePane: PaneResolver
flattenedRouterPanes: FlattenedRouterPane[]
}
export interface CreateResolvedPaneNodeStreamOptions {
/**
* an input stream of `RouterPanes`
* @see RouterPanes
*/
routerPanesStream: Observable<RouterPanes>
/**
* any `UnresolvedPaneNode` (could be an observable, promise, pane resolver etc)
*/
rootPaneNode: UnresolvedPaneNode
/** used primarily for testing */
initialCacheState?: CacheState
structureContext: StructureContext
}
/**
* The result of pane resolving
*/
export type ResolvedPaneMeta = {
groupIndex: number
siblingIndex: number
flatIndex: number
routerPaneSibling: RouterPaneSibling
path: string[]
} & ({type: 'loading'; paneNode: null} | {type: 'resolvedMeta'; paneNode: PaneNode})
interface ResolvePaneTreeOptions {
resolvePane: PaneResolver
flattenedRouterPanes: FlattenedRouterPane[]
unresolvedPane: UnresolvedPaneNode | undefined
parent: PaneNode | null
path: string[]
structureContext: StructureContext
}
/**
* A recursive pane resolving function. Starts at one unresolved pane node and
* continues until there is no more flattened router panes that can be used as
* input to the unresolved panes.
*/
function resolvePaneTree({
unresolvedPane,
flattenedRouterPanes,
parent,
path,
resolvePane,
structureContext,
}: ResolvePaneTreeOptions): Observable<ResolvedPaneMeta[]> {
const [current, ...rest] = flattenedRouterPanes
const next = rest[0] as FlattenedRouterPane | undefined
const context: RouterPaneSiblingContext = {
id: current.routerPaneSibling.id,
splitIndex: current.siblingIndex,
parent,
path: [...path, current.routerPaneSibling.id],
index: current.flatIndex,
params: current.routerPaneSibling.params || {},
payload: current.routerPaneSibling.payload,
structureContext,
}
try {
return resolvePane(unresolvedPane, context, current.flatIndex).pipe(
// this switch map receives a resolved pane
switchMap((paneNode) => {
// we can create a `resolvedMeta` type using it
const resolvedPaneMeta: ResolvedPaneMeta = {
type: 'resolvedMeta',
...current,
paneNode: paneNode,
path: context.path,
}
// for the other unresolved panes, we can create "loading panes"
const loadingPanes = rest.map((i, restIndex) => {
const loadingPanePath = [
...context.path,
...rest.slice(restIndex).map((_, currentIndex) => `[${i.flatIndex + currentIndex}]`),
]
const loadingPane: ResolvedPaneMeta = {
type: 'loading',
path: loadingPanePath,
paneNode: null,
...i,
}
return loadingPane
})
if (!rest.length) {
return observableOf([resolvedPaneMeta])
}
let nextStream
if (
// the fallback editor case
next?.routerPaneSibling.id.startsWith('__edit__')
) {
nextStream = resolvePaneTree({
unresolvedPane: fallbackEditorChild,
flattenedRouterPanes: rest,
parent,
path: context.path,
resolvePane,
structureContext,
})
} else if (current.groupIndex === next?.groupIndex) {
// if the next flattened router pane has the same group index as the
// current flattened router pane, then the next flattened router pane
// belongs to the same group (i.e. it is a split pane)
nextStream = resolvePaneTree({
unresolvedPane,
flattenedRouterPanes: rest,
parent,
path,
resolvePane,
structureContext,
})
} else {
// normal children resolving
nextStream = resolvePaneTree({
unresolvedPane:
typeof paneNode.child === 'function'
? (memoBind(paneNode, 'child') as PaneNodeResolver)
: paneNode.child,
flattenedRouterPanes: rest,
parent: paneNode,
path: context.path,
resolvePane,
structureContext,
})
}
return concat(
// we emit the loading panes first in a concat (this emits immediately)
observableOf([resolvedPaneMeta, ...loadingPanes]),
// then whenever the next stream is done, the results will be combined.
nextStream.pipe(map((nextResolvedPanes) => [resolvedPaneMeta, ...nextResolvedPanes])),
)
}),
)
} catch (e) {
if (e instanceof PaneResolutionError) {
if (e.context) {
console.warn(
`Pane resolution error at index ${e.context.index}${
e.context.splitIndex > 0 ? ` for split pane index ${e.context.splitIndex}` : ''
}: ${e.message}${e.helpId ? ` - see ${generateHelpUrl(e.helpId)}` : ''}`,
e,
)
}
if (e.helpId === 'structure-item-returned-no-child') {
// returning an observable of an empty array will remove loading panes
// note: this one intentionally does not throw
return observableOf([])
}
}
throw e
}
}
/**
* Takes in a stream of `RouterPanes` and an unresolved root pane and returns
* a stream of `ResolvedPaneMeta`
*/
export function createResolvedPaneNodeStream({
routerPanesStream,
rootPaneNode,
initialCacheState = {
cacheKeysByFlatIndex: [],
flattenedRouterPanes: [],
resolvedPaneCache: new Map(),
resolvePane: () => NEVER,
},
structureContext,
}: CreateResolvedPaneNodeStreamOptions): Observable<ResolvedPaneMeta[]> {
const resolvedPanes$ = routerPanesStream.pipe(
// add in implicit "root" router pane
map((rawRouterPanes) => [[{id: 'root'}], ...rawRouterPanes]),
// create flattened router panes
map((routerPanes) => {
const flattenedRouterPanes: FlattenedRouterPane[] = routerPanes
.flatMap((routerPaneGroup, groupIndex) =>
routerPaneGroup.map((routerPaneSibling, siblingIndex) => ({
routerPaneSibling,
groupIndex,
siblingIndex,
})),
)
// add in the flat index
.map((i, index) => ({...i, flatIndex: index}))
return flattenedRouterPanes
}),
// calculate a "diffIndex" used for clearing the memo cache
startWith([] as FlattenedRouterPane[]),
pairwise(),
map(([prev, curr]) => {
for (let i = 0; i < curr.length; i++) {
const prevValue = prev[i]
const currValue = curr[i]
if (!isEqual(prevValue, currValue)) {
return {
flattenedRouterPanes: curr,
diffIndex: i,
}
}
}
return {
flattenedRouterPanes: curr,
diffIndex: curr.length,
}
}),
// create the memoized `resolvePane` function and manage the memo cache
scan((acc, next) => {
const {cacheKeysByFlatIndex, resolvedPaneCache} = acc
const {flattenedRouterPanes, diffIndex} = next
// use the `cacheKeysByFlatIndex` like a dictionary to find cache keys to
// and cache keys to delete
const beforeDiffIndex = cacheKeysByFlatIndex.slice(0, diffIndex + 1)
const afterDiffIndex = cacheKeysByFlatIndex.slice(diffIndex + 1)
const keysToKeep = new Set(beforeDiffIndex.flatMap((keySet) => Array.from(keySet)))
const keysToDelete = afterDiffIndex
.flatMap((keySet) => Array.from(keySet))
.filter((key) => !keysToKeep.has(key))
for (const key of keysToDelete) {
resolvedPaneCache.delete(key)
}
// create a memoizing pane resolver middleware that utilizes the cache
// maintained above. this keeps the cache from growing indefinitely
const memoize: PaneResolverMiddleware = (nextFn) => (unresolvedPane, context, flatIndex) => {
const key = unresolvedPane && `${assignId(unresolvedPane)}-${hashContext(context)}`
const cachedResolvedPane = key && resolvedPaneCache.get(key)
if (cachedResolvedPane) return cachedResolvedPane
const result = nextFn(unresolvedPane, context, flatIndex)
if (!key) return result
const cacheKeySet = cacheKeysByFlatIndex[flatIndex] || new Set()
cacheKeySet.add(key)
cacheKeysByFlatIndex[flatIndex] = cacheKeySet
resolvedPaneCache.set(key, result)
return result
}
return {
flattenedRouterPanes,
cacheKeysByFlatIndex,
resolvedPaneCache,
resolvePane: createPaneResolver(memoize),
}
}, initialCacheState),
// run the memoized, recursive resolving
switchMap(({flattenedRouterPanes, resolvePane}) =>
resolvePaneTree({
unresolvedPane: rootPaneNode,
flattenedRouterPanes,
parent: null,
path: [],
resolvePane,
structureContext,
}),
),
)
// after we've created a stream of `ResolvedPaneMeta[]`, we need to clean up
// the results to remove unwanted loading panes and prevent unnecessary
// emissions
return resolvedPanes$.pipe(
// this diffs the previous emission with the current one. if there is a new
// loading pane at the same position where a previous pane already had a
// resolved value (looking at the IDs to compare), then return the previous
// pane instead of the loading pane
scan(
(prev, next) =>
next.map((nextPane, index) => {
const prevPane = prev[index] as ResolvedPaneMeta | undefined
if (!prevPane) return nextPane
if (nextPane.type !== 'loading') return nextPane
if (prevPane.routerPaneSibling.id === nextPane.routerPaneSibling.id) {
return prevPane
}
return nextPane
}),
[] as ResolvedPaneMeta[],
),
// this prevents duplicate emissions
distinctUntilChanged((prev, next) => {
if (prev.length !== next.length) return false
for (let i = 0; i < next.length; i++) {
const prevValue = prev[i]
const nextValue = next[i]
if (hashResolvedPaneMeta(prevValue) !== hashResolvedPaneMeta(nextValue)) {
return false
}
}
return true
}),
)
}