UNPKG

@y/y

Version:

Shared Editing Library

2,876 lines • 117 kB
import * as binary from 'lib0/binary'
import * as array from 'lib0/array'
import * as delta from 'lib0/delta'
import * as error from 'lib0/error'
import * as iterator from 'lib0/iterator'
import * as log from 'lib0/logging'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import * as math from 'lib0/math'
import * as object from 'lib0/object'
import * as s from 'lib0/schema'
import * as traits from 'lib0/traits'
import { ObservableV2 } from 'lib0/observable'
import {
  Item,
  ContentAny,
  ContentBinary,
  ContentDeleted,
  ContentEmbed,
  ContentFormat,
  ContentJSON,
  ContentString,
  ContentType,
  YXmlFragmentRefID,
  YXmlElementRefID,
  YXmlHookRefID,
  ContentDoc,
  createContentDocFromDoc
} from './structs/Item.js'
import { AttributedContent, rendererContentLength } from './utils/renderer-helpers.js'
import { removeEventHandlerListener, callEventHandlerListeners, addEventHandlerListener, createEventHandler } from './utils/EventHandler.js'
import { createID } from './utils/ID.js'
import { createIdSet, iterateStructsByIdSetWithoutSplits } from './utils/ids.js'
import { getItemCleanStart, cleanupFormattingGap } from './utils/transaction-helpers.js'
import { transact } from './utils/Transaction.js'
import { YEvent } from './utils/YEvent.js'
import { $doc } from './utils/schemas.js'

/**
 * @typedef {Object<string,any>|Array<any>|number|null|string|Uint8Array|BigInt|YNode<any>} YValue
 */

/**
 * https://docs.yjs.dev/getting-started/working-with-shared-types#caveats
 */
export const warnPrematureAccess = () => { log.warn('Invalid access: Add Yjs type to a document before reading data.') }

const maxSearchMarker = 80

/**
 * @todo SHOULD NOT RETURN AN OBJECT!
 * @param {Array<ContentAttribute<any>>?} attrs
 * @param {boolean} deleted - whether the attributed item is deleted
 * @return {Attribution|undefined} `undefined` when there is no attribution — under lib0's tri-state
 * that means "skip / inherit the builder's attribution context" (NOT `null`, which would clear it).
 */
export const createAttributionFromAttributionItems = (attrs, deleted) => {
  if (attrs == null) {
    return undefined
  }
  /**
   * @type {Attribution}
   */
  const attribution = {}
  if (deleted) {
    attribution.delete = []
  } else {
    attribution.insert = []
  }
  attrs.forEach(attr => {
    switch (attr.name) {
      // eslint-disable-next-line no-fallthrough
      case 'insert':
      case 'delete': {
        // needs to be non-ambiguous: don't add existing attr if it doesn't match the actual status
        attribution[attr.name]?.push(attr.val)
        break
      }
      default: {
        if (attr.name[0] !== '_') {
          /** @type {any} */ (attribution)[attr.name] = attr.val
        }
      }
    }
  })
  return attribution
}

/**
 * A unique timestamp that identifies each marker.
 *
 * Time is relative,.. this is more like an ever-increasing clock.
 *
 * @type {number}
 */
let globalSearchMarkerTimestamp = 0

export class ItemTextListPosition {
  /**
   * @param {Item|null} left
   * @param {Item|null} right
   * @param {number} index
   * @param {Map<string,any>} currentFormats
   * @param {AbstractRenderer?} renderer
   */
  constructor (left, right, index, currentFormats, renderer) {
    this.left = left
    this.right = right
    this.index = index
    this.currentFormats = currentFormats
    this.renderer = renderer
  }

  /**
   * Only call this if you know that this.right is defined
   */
  forward () {
    if (this.right === null) {
      error.unexpectedCase()
    }
    switch (this.right.content.constructor) {
      case ContentFormat:
        if (!this.right.deleted) {
          updateCurrentFormats(this.currentFormats, /** @type {ContentFormat} */ (this.right.content))
        }
        break
      default:
        this.index += rendererContentLength(this.renderer, this.right)
        break
    }
    this.left = this.right
    this.right = this.right.right
  }

  /**
   * @param {Transaction} transaction
   * @param {YNode} parent
   * @param {number} length
   * @param {Object<string,any>} formats
   *
   * @function
   */
  formatText (transaction, parent, length, formats) {
    minimizeFormatChanges(this, formats)
    const negatedFormats = insertFormats(transaction, parent, this, formats)
    // iterate until first non-format or null is found
    // delete all formats with formats[format.key] != null
    // also check the formats after the first non-format as we do not want to insert redundant negated formats there
    // eslint-disable-next-line no-labels
    iterationLoop: while (
      this.right !== null &&
      (length > 0 ||
        (
          negatedFormats.size > 0 &&
          ((this.right.deleted && rendererContentLength(this.renderer, this.right) === 0) || this.right.content.constructor === ContentFormat)
        )
      )
    ) {
      switch (this.right.content.constructor) {
        case ContentFormat: {
          if (!this.right.deleted) {
            const { key, value } = /** @type {ContentFormat} */ (this.right.content)
            const attr = formats[key]
            if (attr !== undefined) {
              if (equalFormats(attr, value)) {
                negatedFormats.delete(key)
              } else {
                if (length === 0) {
                  // no need to further extend negatedFormats
                  // eslint-disable-next-line no-labels
                  break iterationLoop
                }
                negatedFormats.set(key, value)
              }
              this.right.delete(transaction)
            } else {
              this.currentFormats.set(key, value)
            }
          }
          break
        }
        default: {
          const item = this.right
          const rightLen = rendererContentLength(this.renderer, item)
          if (length < rightLen) {
            if (this.renderer !== null && this.renderer.hasItem(item)) {
              /**
               * @type {Array<AttributedContent<any>>}
               */
              const contents = []
              this.renderer.readContent(contents, item.id.client, item.id.clock, item.deleted, item.content, 0)
              let i = 0
              for (; i < contents.length && length > 0; i++) {
                const c = contents[i]
                if ((!c.deleted || c.attrs != null) && c.content.isCountable()) {
                  length -= c.content.getLength()
                }
              }
              if (length < 0 || (length === 0 && i !== contents.length)) {
                const c = contents[--i]
                getItemCleanStart(transaction, createID(item.id.client, c.clock + c.content.getLength() + length))
              }
            } else {
              // plain content: split directly at the offset
              getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length))
              length = 0
            }
          } else {
            length -= rightLen
          }
          break
        }
      }
      this.forward()
    }
    if (length > 0) {
      throw new Error('Exceeded content range')
    }
    insertNegatedFormats(transaction, parent, this, negatedFormats)
  }
}

/**
 * Negate applied formats
 *
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {ItemTextListPosition} currPos
 * @param {Map<string,any>} negatedFormats
 *
 * @private
 * @function
 */
const insertNegatedFormats = (transaction, parent, currPos, negatedFormats) => {
  // check if we really need to remove formats
  while (
    currPos.right !== null && (
      (currPos.right.deleted && rendererContentLength(currPos.renderer, currPos.right) === 0) || (
        currPos.right.content.constructor === ContentFormat &&
        equalFormats(negatedFormats.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value)
      )
    )
  ) {
    if (!currPos.right.deleted) {
      negatedFormats.delete(/** @type {ContentFormat} */ (currPos.right.content).key)
    }
    currPos.forward()
  }
  const doc = transaction.doc
  const ownClientId = doc.clientID
  negatedFormats.forEach((val, key) => {
    const left = currPos.left
    const right = currPos.right
    const nextFormat = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
    nextFormat.integrate(transaction, 0)
    currPos.right = nextFormat
    currPos.forward()
  })
}

/**
 * @param {Map<string,any>} currentFormats
 * @param {ContentFormat} format
 *
 * @private
 * @function
 */
const updateCurrentFormats = (currentFormats, format) => {
  const { key, value } = format
  if (value === null) {
    currentFormats.delete(key)
  } else {
    currentFormats.set(key, value)
  }
}

/**
 * @param {ItemTextListPosition} currPos
 * @param {Object<string,any>} formats
 *
 * @private
 * @function
 */
const minimizeFormatChanges = (currPos, formats) => {
  // go right while formats[right.key] === right.value (or right is deleted)
  while (true) {
    if (currPos.right === null) {
      break
    } else if (currPos.right.deleted ? (rendererContentLength(currPos.renderer, currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalFormats(formats[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) {
      //
    } else {
      break
    }
    currPos.forward()
  }
}

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {ItemTextListPosition} currPos
 * @param {Object<string,any>} formats
 * @return {Map<string,any>}
 *
 * @private
 * @function
 **/
const insertFormats = (transaction, parent, currPos, formats) => {
  const doc = transaction.doc
  const ownClientId = doc.clientID
  const negatedFormats = new Map()
  // insert format-start items
  for (const key in formats) {
    const val = formats[key]
    const currentVal = currPos.currentFormats.get(key) ?? null
    if (!equalFormats(currentVal, val)) {
      // save negated format (set null if currentVal undefined)
      negatedFormats.set(key, currentVal)
      const { left, right } = currPos
      currPos.right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
      currPos.right.integrate(transaction, 0)
      currPos.forward()
    }
  }
  return negatedFormats
}

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {ItemTextListPosition} currPos
 * @param {import('./structs/Item.js').AbstractContent} content
 * @param {Object<string,any>} formats
 *
 * @private
 * @function
 **/
export const insertContent = (transaction, parent, currPos, content, formats) => {
  currPos.currentFormats.forEach((_val, key) => {
    if (formats[key] === undefined) {
      formats[key] = null
    }
  })
  const doc = transaction.doc
  const ownClientId = doc.clientID
  minimizeFormatChanges(currPos, formats)
  const negatedFormats = insertFormats(transaction, parent, currPos, formats)
  let { left, right, index } = currPos
  if (parent._searchMarker) {
    updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength())
  }
  right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
  right.integrate(transaction, 0)
  currPos.right = right
  currPos.index = index
  currPos.forward()
  insertNegatedFormats(transaction, parent, currPos, negatedFormats)
}

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {ItemTextListPosition} currPos
 * @param {Array<any>|string} insert
 * @param {Object<string,any>} formats
 */
export const insertContentHelper = (transaction, parent, currPos, insert, formats) => {
  if (s.$string.check(insert)) {
    insertContent(transaction, parent, currPos, new ContentString(insert), formats)
  } else {
    insert = insert.map(ins => delta.$deltaAny.check(ins) ? YNode.from(ins) : ins)
    for (let i = 0; i < insert.length;) {
      const first = insert[i]
      if (first instanceof YNode) {
        insertContent(transaction, parent, currPos, new ContentType(first), formats)
        i++
      } else if ($doc.check(first)) {
        insertContent(transaction, parent, currPos, createContentDocFromDoc(first), formats)
        i++
      } else {
        // insert "any" content
        // compute slice len
        let j = i + 1
        for (; j < insert.length && !(insert[j] instanceof YNode || $doc.check(insert[j])); j++) { /* nop */ }
        insertContent(transaction, parent, currPos, new ContentAny((i === 0 && j === insert.length) ? insert : insert.slice(i, j)), formats)
        i = j
      }
    }
  }
}

/**
 * @param {Transaction} transaction
 * @param {ItemTextListPosition} currPos
 * @param {number} length
 * @return {ItemTextListPosition}
 *
 * @private
 * @function
 */
export const deleteText = (transaction, currPos, length) => {
  const startLength = length
  const startFormats = map.copy(currPos.currentFormats)
  const start = currPos.right
  while (length > 0 && currPos.right !== null) {
    const item = currPos.right
    if (!item.deleted && item.countable) {
      if (length < item.length) {
        getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length))
      }
      length -= item.length
      item.delete(transaction)
    } else if (currPos.renderer !== null && currPos.renderer.hasItem(item)) {
      /**
       * @type {Array<AttributedContent<any>>}
       */
      const contents = []
      currPos.renderer.readContent(contents, item.id.client, item.id.clock, true, item.content, 0)
      let splitClock = -1
      for (let i = 0; i < contents.length; i++) {
        const c = contents[i]
        if (c.content.isCountable() && c.attrs != null) {
          if (length === 0) {
            // the delete is exhausted but this item renders more content — split so the cursor
            // advances only past the consumed part (mirrors formatText's renderer branch);
            // otherwise every following op of the same delta targets too far right
            splitClock = c.clock
            break
          }
          // deleting already deleted content. store that information in a meta property, but do
          // nothing
          const pieceLen = c.content.getLength()
          const contentLen = math.min(pieceLen, length)
          map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen)
          length -= contentLen
          if (contentLen < pieceLen) {
            splitClock = c.clock + contentLen
            break
          }
        }
      }
      if (splitClock >= 0) {
        getItemCleanStart(transaction, createID(item.id.client, splitClock))
      } else {
        const lastContent = contents.length > 0 ? contents[contents.length - 1] : null
        const nextItemClock = item.id.clock + item.length
        const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock
        if (nextContentClock < nextItemClock) {
          getItemCleanStart(transaction, createID(item.id.client, nextContentClock))
        }
      }
    }
    currPos.forward()
  }
  if (start) {
    cleanupFormattingGap(transaction, start, currPos.right, startFormats, currPos.currentFormats)
  }
  const parent = /** @type {YNode<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
  if (parent._searchMarker) {
    updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length)
  }
  return currPos
}

export class ArraySearchMarker {
  /**
   * @param {Item} p
   * @param {number} index
   */
  constructor (p, index) {
    p.marker = true
    this.p = p
    this.index = index
    this.timestamp = globalSearchMarkerTimestamp++
  }
}

/**
 * @param {ArraySearchMarker} marker
 */
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }

/**
 * This is rather complex so this function is the only thing that should overwrite a marker
 *
 * @param {ArraySearchMarker} marker
 * @param {Item} p
 * @param {number} index
 */
const overwriteMarker = (marker, p, index) => {
  marker.p.marker = false
  marker.p = p
  p.marker = true
  marker.index = index
  marker.timestamp = globalSearchMarkerTimestamp++
}

/**
 * @param {Array<ArraySearchMarker>} searchMarker
 * @param {Item} p
 * @param {number} index
 */
const markPosition = (searchMarker, p, index) => {
  if (searchMarker.length >= maxSearchMarker) {
    // override oldest marker (we don't want to create more objects)
    const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
    overwriteMarker(marker, p, index)
    return marker
  } else {
    // create new marker
    const pm = new ArraySearchMarker(p, index)
    searchMarker.push(pm)
    return pm
  }
}

/**
 * Search marker help us to find positions in the associative array faster.
 *
 * They speed up the process of finding a position without much bookkeeping.
 *
 * A maximum of `maxSearchMarker` objects are created.
 *
 * This function always returns a refreshed marker (updated timestamp)
 *
 * @param {YNode} yarray
 * @param {number} index
 */
export const findMarker = (yarray, index) => {
  if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
    return null
  }
  const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
  let p = yarray._start
  let pindex = 0
  if (marker !== null) {
    p = marker.p
    pindex = marker.index
    refreshMarkerTimestamp(marker) // we used it, we might need to use it again
  }
  // iterate to right if possible
  while (p.right !== null && pindex < index) {
    if (!p.deleted && p.countable) {
      if (index < pindex + p.length) {
        break
      }
      pindex += p.length
    }
    p = p.right
  }
  // iterate to left if necessary (might be that pindex > index)
  while (p.left !== null && pindex > index) {
    p = p.left
    if (!p.deleted && p.countable) {
      pindex -= p.length
    }
  }
  // we want to make sure that p can't be merged with left, because that would screw up everything
  // in that cas just return what we have (it is most likely the best marker anyway)
  // iterate to left until p can't be merged with left
  while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
    p = p.left
    if (!p.deleted && p.countable) {
      pindex -= p.length
    }
  }
  if (marker !== null && math.abs(marker.index - pindex) < /** @type {any} */ (p.parent).length / maxSearchMarker) {
    // adjust existing marker
    overwriteMarker(marker, p, pindex)
    return marker
  } else {
    // create new marker
    return markPosition(yarray._searchMarker, p, pindex)
  }
}

/**
 * Update markers when a change happened.
 *
 * This should be called before doing a deletion!
 *
 * @param {Array<ArraySearchMarker>} searchMarker
 * @param {number} index
 * @param {number} len If insertion, len is positive. If deletion, len is negative.
 */
export const updateMarkerChanges = (searchMarker, index, len) => {
  for (let i = searchMarker.length - 1; i >= 0; i--) {
    const m = searchMarker[i]
    if (len > 0) {
      /**
       * @type {Item|null}
       */
      let p = m.p
      p.marker = false
      // Ideally we just want to do a simple position comparison, but this will only work if
      // search markers don't point to deleted items for formats.
      // Iterate marker to prev undeleted countable position so we know what to do when updating a position
      while (p && (p.deleted || !p.countable)) {
        p = p.left
        if (p && !p.deleted && p.countable) {
          // adjust position. the loop should break now
          m.index -= p.length
        }
      }
      if (p === null || p.marker === true) {
        // remove search marker if updated position is null or if position is already marked
        searchMarker.splice(i, 1)
        continue
      }
      m.p = p
      p.marker = true
    }
    if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
      m.index = math.max(index, m.index + len)
    }
  }
}

/**
 * Accumulate all (list) children of a type and return them as an Array.
 *
 * @param {YNode} t
 * @return {Array<Item>}
 */
export const getNodeChildren = t => {
  t.doc ?? warnPrematureAccess()
  let s = t._start
  const arr = []
  while (s) {
    arr.push(s)
    s = s.right
  }
  return arr
}

/**
 * Call event listeners with an event. This will also add an event to all
 * parents (for `.observeDeep` handlers).
 *
 * @param {YNode} type
 * @param {Transaction} transaction
  * @param {YEvent<any>} event
 */
export const callTypeObservers = (type, transaction, event) => {
  const changedType = type
  const changedParentTypes = transaction.changedParentTypes
  // track the event unconditionally — also for deleted types: the walk carries changes inside a
  // deleted type (e.g. a suggestion-deleted tombstone that a custom renderer still renders) up to
  // the deep-event / 'delta' / cache consumers on live ancestors.
  while (true) {
    // @ts-ignore
    map.setIfUndefined(changedParentTypes, type, () => []).push(event)
    if (type._item === null) {
      break
    }
    type = /** @type {YNode} */ (type._item.parent)
  }
  // a deleted type's own observers stay silent (deleted content is invisible) — unless a
  // renderer is attached to it, which may still render the type (mirrors the fire-time rule for
  // `changedParentTypes` targets in `cleanupTransactions`).
  if (changedType._item === null || !changedType._item.deleted || changedType._renderer !== null) {
    callEventHandlerListeners(/** @type {any} */ (changedType._eH), event, transaction)
  }
}

/**
 * Abstract Yjs Type class.
 *
 * A `YNode` is a {@link https://github.com/dmonad/lib0 lib0} `RDT` ("replicated data type", see
 * `lib0/delta/rdt.js`): it emits a `'delta'` event whenever its state changes (carrying the change
 * and the origin of the transaction that caused it), accepts foreign
 * changes via {@link YNode#applyDelta}, exposes its delta {@link YNode#$delta schema}, and can be
 * torn down via {@link YNode#destroy}. This lets a `YNode` be `bind()`-ed to any other RDT (another
 * `YNode`, an in-memory delta, a DOM subtree, …). The legacy {@link YNode#observe `observe`} /
 * {@link YNode#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'`
 * channel.
 *
 * @template {delta.DeltaConf} [DConf=any]
 * @extends {ObservableV2<{ delta: (delta: delta.Delta<DConf>, origin: any) => void, destroy: (type: YNode<DConf>) => void }>}
 */
export class YNode extends ObservableV2 {
  /**
   * @param {delta.DeltaConfGetName<DConf>?} name
   */
  constructor (name = null) {
    super()
    /**
     * @type {delta.DeltaConfGetName<DConf>}
     */
    this.name = /** @type {delta.DeltaConfGetName<DConf>} */ (name)
    /**
     * @type {Item|null}
     */
    this._item = null
    /**
     * @type {Map<string,Item>}
     */
    this._map = new Map()
    /**
     * @type {Item|null}
     */
    this._start = null
    /**
     * @type {Doc|null}
     */
    this.doc = null
    this._length = 0
    /**
     * Event handlers
     * @type {EventHandler<YEvent<DeltaToYNode<DConf>>,Transaction>}
     */
    this._eH = createEventHandler()
    /**
     * Deep event handlers
     * @type {EventHandler<YEvent<DConf>,Transaction>}
     */
    this._dEH = createEventHandler()
    /**
     * @type {null | Array<ArraySearchMarker>}
     */
    this._searchMarker = null
    /**
     * Maintained deep-delta cache backing {@link YNode#delta}. `null` until `delta` is first
     * accessed; thereafter kept current on every event of this type (incrementally, by applying the
     * deep change) and re-diffed by {@link YNode#useRenderer}. Cleared by {@link YNode#clearCache}.
     *
     * INVARIANT (fingerprint-memo safety): consumers (e.g. y-sync) diff against this LIVE object
     * via {@link YNode#delta}, which memoizes `_fingerprint` on its delta/op nodes at every depth.
     * Every in-place patch — the per-transaction `apply` in `cleanupTransactions` and the
     * renderer-overlay `apply` in {@link typeApplyRendererChange} — MUST route through the lib0
     * delta builder API (`apply`), which invalidates those memos. NEVER write delta/op fields
     * directly: a direct write leaves stale memos, corrupting every subsequent fingerprint read
     * and diff. Pinned by the `testRdtFingerprintMemo*CacheDrift` tests.
     * @type {delta.DeltaBuilderAny | null}
     */
    this._delta = null
    this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID
    /**
     * @type {Array<ArraySearchMarker>|null}
     */
    this._searchMarker = []
    /**
     * Whether this YText contains formats.
     * This flag is updated when a formatting item is integrated (see ContentFormat.integrate)
     */
    this._hasFormatting = false
    /**
     * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no
     * explicit renderer is passed. `null` = no renderer: content renders as-is via the generic
     * fast path. Change it via {@link YNode#useRenderer}.
     * @type {AbstractRenderer?}
     */
    this._renderer = null
    /**
     * Bound listener on the active renderer's `'change'` event — attribution corrections that
     * happen without a Y transaction on this doc (e.g. a suggestion is accepted and the
     * renderer's attribution overlay updates). `null` while no renderer is active.
     * Managed by {@link YNode#useRenderer}; see {@link typeApplyRendererChange}.
     * @type {((changes: IdSet, origin: any, local: boolean) => void) | null}
     */
    this._rendererChangeHandler = null
  }

  /**
   * Schema of the deltas this type produces — part of the lib0 `RDT` interface.
   *
   * @type {s.Schema<delta.Delta<DConf>>}
   */
  get $delta () {
    return /** @type {any} */ (delta.$deltaAny)
  }

  /**
   * The deep delta of this type (the full nested content tree, children rendered as their own
   * deltas).
   *
   * The returned value is the type's **live** maintained cache: it is materialized on first access
   * and then kept current on every event fired on this type (and re-diffed by
   * {@link YNode#useRenderer}), so a reference held across edits keeps updating in place. Clone it
   * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YNode#clearCache} to
   * drop the cache.
   *
   * Consider the returned delta **done** — it must not be edited from the outside. It is
   * deliberately typed as a `Delta` (not a `DeltaBuilder`) so the mutating builder API is not
   * reachable; editing it anyway would corrupt the cache without changing the CRDT. The proper way
   * to change this type is {@link YNode#applyDelta}.
   *
   * @type {delta.Delta<DConf>}
   */
  get delta () {
    if (this._delta === null) {
      this._delta = this._renderDelta()
    }
    return /** @type {any} */ (this._delta)
  }

  /**
   * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of
   * deep changes update content in place). Uses this type's active renderer.
   *
   * @return {delta.DeltaBuilderAny}
   */
  _renderDelta () {
    const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name))
    state.isFinal = true
    state.apply(this.toDelta({ deep: true }))
    return state
  }

  /**
   * Discard the cached deep delta backing {@link YNode#delta}.
   *
   * After `delta` is first accessed, the cache is updated on every event fired on this type (and
   * re-diffed by {@link YNode#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to
   * force an exact recomputation after editing while a non-base renderer is active (the incremental
   * updates can drift from a fresh deep render in that case).
   */
  clearCache () {
    this._delta = null
  }

  /**
   * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the
   * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is
   * passed (an explicit `{ renderer }` argument still overrides it per call).
   *
   * If the deep-delta cache ({@link YNode#delta}) is being maintained, or a `'delta'` listener is
   * attached, the content is re-rendered with the new renderer and the difference is emitted on the
   * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced, and
   * the emitted origin is `null` as no transaction is involved).
   *
   * @param {AbstractRenderer?} renderer - `null` detaches: content renders as-is again
   * @return {this}
   */
  useRenderer (renderer) {
    const prev = this._renderer
    if (renderer === prev) return this
    if (this._rendererChangeHandler !== null) {
      /** @type {AbstractRenderer} */ (prev).off('change', this._rendererChangeHandler)
      this._rendererChangeHandler = null
    }
    if (renderer !== null && this._rendererChangeHandler === null) {
      // attribution corrections (e.g. accepting a suggestion) reach the renderer without a Y
      // transaction on this doc — subscribe so the RDT surface (maintained cache + 'delta'
      // channel) stays current. Without a renderer there are no attributions, hence no
      // subscription. Not gated on `renderer !== prev`: after `destroy()` the handler is removed
      // while `_renderer` keeps pointing at the renderer, so re-activating with the same renderer
      // must re-subscribe.
      this._rendererChangeHandler = (changes, origin) => typeApplyRendererChange(this, changes, origin)
      renderer.on('change', this._rendererChangeHandler)
    }
    const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0
    if (this._delta !== null || hasDeltaListeners) {
      const oldState = this._delta ?? this._renderDelta()
      this._renderer = renderer
      const newState = this._renderDelta()
      if (this._delta !== null) this._delta = newState
      if (hasDeltaListeners) {
        const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState)))
        if (!d.isEmpty()) this.emit('delta', [d, null])
      }
    } else {
      this._renderer = renderer
    }
    return this
  }

  /**
   * Tear down this type as an `RDT`: emit the `'destroy'` event, unregister all `'delta'` /
   * `'destroy'` listeners, and reset the RDT surface (active renderer detached, the
   * maintained {@link YNode#delta} cache dropped). The CRDT content and the
   * `observe`/`observeDeep` handlers are left untouched — this only releases the RDT/binding
   * observers. Without the reset, a still-materialized cache would keep being maintained through
   * the transaction path but no longer receive the renderer's attribution corrections — reading
   * `delta` after `destroy()` would silently drift. Re-activating is fully supported:
   * `useRenderer(renderer)` re-subscribes and `delta` re-materializes.
   */
  destroy () {
    if (this._rendererChangeHandler !== null) {
      /** @type {AbstractRenderer} */ (this._renderer).off('change', this._rendererChangeHandler)
      this._rendererChangeHandler = null
    }
    this._renderer = null
    this._delta = null
    this.emit('destroy', [this])
    super.destroy()
  }

  /**
   * @template {delta.DeltaConf} DC
   * @param {delta.Delta<DC>} d
   * @return {YNode<DC>}
   */
  static from (d) {
    const yt = new YNode(d.name)
    yt.applyDelta(d)
    return yt
  }

  get length () {
    this.doc ?? warnPrematureAccess()
    return this._length
  }

  /**
   * Returns a fresh delta that can be used to change this YNode.
   * @type {delta.DeltaBuilder<DeltaToYNode<DConf>>}
   */
  get change () {
    return /** @type {any} */ (delta.create())
  }

  /**
   * @return {YNode<any>?}
   */
  get parent () {
    return /** @type {YNode<any>?} */ (this._item ? this._item.parent : null)
  }

  /**
   * Integrate this type into the Yjs instance.
   *
   * * Save this struct in the os
   * * This type is sent to other client
   * * Observer functions are fired
   *
   * @param {Doc} y The Yjs instance
   * @param {Item|null} item
   */
  _integrate (y, item) {
    this.doc = y
    this._item = item
    if (this._prelim) {
      this.applyDelta(this._prelim)
      this._prelim = null
    }
  }

  /**
   * @return {YNode<DConf>}
   */
  _copy () {
    const ynode = new YNode(this.name)
    ynode._legacyTypeRef = this._legacyTypeRef
    return ynode
  }

  /**
   * Creates YEvent and calls all type observers.
   * Must be implemented by each type.
   *
   * @param {Transaction} transaction
   * @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
   */
  _callObserver (transaction, parentSubs) {
    const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs)
    callTypeObservers(/** @type {any} */ (this), transaction, event)
    // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction
    // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like
    // `observeDeep`, not here where only the directly-changed type is visible.
    if (!transaction.local && this._searchMarker) {
      this._searchMarker.length = 0
    }
    // If a remote change happened, we try to cleanup potential formatting duplicates.
    if (!transaction.local && this._hasFormatting) {
      transaction._needFormattingCleanup = true
    }
  }

  /**
   * Observe all events that are created on this type.
   *
   * @template {(target: YEvent<DeltaToYNode<DConf>>, tr: Transaction) => void} F
   * @param {F} f Observer function
   * @return {F}
   */
  observe (f) {
    addEventHandlerListener(this._eH, f)
    return f
  }

  /**
   * Observe all events that are created by this type and its children.
   *
   * @template {function(YEvent<DConf>,Transaction):void} F
   * @param {F} f Observer function
   * @return {F}
   */
  observeDeep (f) {
    addEventHandlerListener(this._dEH, f)
    return f
  }

  /**
   * Unregister an observer function.
   *
   * @param {(type:YEvent<DeltaToYNode<DConf>>,tr:Transaction)=>void} f Observer function
   */
  unobserve (f) {
    removeEventHandlerListener(this._eH, f)
  }

  /**
   * Unregister an observer function.
   *
   * @param {function(YEvent<DConf>,Transaction):void} f Observer function
   */
  unobserveDeep (f) {
    removeEventHandlerListener(this._dEH, f)
  }

  /**
   * Render the difference to another ydoc (which can be empty) and highlight the differences with
   * attributions.
   *
   * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the
   * attribution `{ isDeleted: true, .. }`.
   *
   * @template {boolean} [Deep=false]
   *
   * @param {Object} [opts]
   * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
   * @param {IdSet?} [opts.itemsToRender] - render only the change made up of these ids instead of the full state. Leave churn out (ids both inserted and deleted by the change — list rendering would emit deletes for content the consuming state never held) and pass the change's inserts as `insertedItems`
   * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions
   * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only
   * @param {IdSet?} [opts.insertedItems] - ids inserted by the change being rendered; content that is both in `insertedItems` and deleted renders as a *fresh* insert even under `retainDeletes` (it was never part of the consuming state). It also identifies churn for map keys: a key overwritten and then deleted by the change has a churn item as its latest value, and `insertedItems` lets the render step over it to the deleted earlier value and emit the `deleteAttr` (a step bounded by the change, never a walk of the key's history). Without `insertedItems` that `deleteAttr` is missed unless the tombstones already merged into one item
   * @param {Map<YNode,Set<string|null>>|null} [opts.modified] - set of types that should be rendered as modified children
   * @param {Deep} [opts.deep] - render child types as delta
   * @return {Deep extends true ? delta.Delta<DConf> : delta.Delta<DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
   *
   * @public
   */
  toDelta (opts = {}) {
    const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, insertedItems = null, deep = false } = opts
    const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts
    // change-scoped render (`itemsToRender` given): a type that is neither in `modified` nor itself
    // inserted by the change holds nothing of the change — render no attrs instead of falling back
    // to "render all" (which would re-emit every attr as a `setAttr`, with nested values rendered
    // change-scoped, i.e. partial). A type whose own item is in the change set is a fresh insert
    // and must render in full; `transaction.changed` never lists types created in that transaction.
    const renderAttrs = modified?.get(this) || ((modified !== null && itemsToRender !== null && !(this._item !== null && itemsToRender.hasId(this._item.lastId))) ? emptyAttrSet : null)
    const renderChildren = modified == null || !modified.has(this) || /** @type {Set<string|null>} */ (modified.get(this)).has(null)
    /**
     * @type {delta.DeltaBuilderAny}
     */
    const d = /** @type {any} */ (delta.create(this.name))
    const optsAll = object.assign({}, opts, { renderer, modified })
    // opts has been re-computed - do not use opts after this point!
    nodeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, itemsToRender, optsAll, optsAll)
    if (renderChildren) {
      /**
       * @type {delta.Formats}
       */
      let currentFormats = {} // saves all current formats for insert
      let usingCurrentFormats = false
      /**
       * @type {delta.Formats}
       */
      let changedFormats = {} // saves changed formats for retain
      let usingChangedFormats = false
      /**
       * Logic for format attribution
       * Everything that comes after a format is formatted by the user that created it.
       * Two exceptions:
       * - the user resets formatting to the previously known formatting that is not attributed
       * - the user deletes a format and hence restores the previously known formatting
       *   that is not attributed.
       * @type {delta.Formats}
       */
      const previousUnattributedFormats = {} // contains previously known unattributed formatting
      /**
       * @type {delta.Formats}
       */
      const previousFormats = {} // The value before changes
      /**
       * Per-key provenance of `currentFormats`: `true` iff the marker currently governing the key
       * opened a *pending attributed* range (the attributor-push branch below). Consulted only at
       * deleted markers in change renders (`isDeletedFormatClear`) to distinguish a re-exposed
       * committed enclosing value (must clear the stale attribution from the cache) from a
       * re-exposed pending suggested value (must preserve it — the re-bolding case).
       * @type {{ [key: string]: boolean }}
       */
      const currentFormatsAttributed = {}
      /**
       * Attribution argument for a change-render retain whose content lost its *own* attribution.
       * `null` clears everything — correct in an attribution-free context — but inside an ambient
       * format attribution (installed via `useAttribution` by a surrounding format marker) the
       * fresh render still stamps that ambient format on this content. An object argument merges
       * over the ambient context (lib0 `combineInstr`), so emit per-key removals instead: they
       * clear only the op's own insert/delete attribution while re-asserting the ambient format.
       */
      const clearedOwnAttribution = () => d.usedAttribution == null ? null : /** @type {any} */ ({ insert: null, insertAt: null, delete: null, deleteAt: null })
      /**
       * Format keys touched by a *rendered* format marker during an attributing change render. A
       * merely retained attributed marker normally must not emit ops — but when a rendered
       * same-key marker preceded it in this walk, the change is what made the retained boundary
       * reset-to-previous (e.g. a base-doc format arriving under a same-key format suggestion),
       * and the spans it governs must drop their now-stale attribution from the cache. Per-key
       * tracking is required (a plain "any format rendered" flag would over-clear other keys),
       * but the set is only ever consulted at format markers — never per character — and stays
       * `null` on all hot paths: full renders, base-renderer renders, and change renders without
       * format markers never allocate it.
       * @type {Set<string>?}
       */
      let renderedFormatKeys = null
      /**
       * Renderer output for a single item — a renderer may split an item into multiple
       * attributed pieces.
       * @type {Array<AttributedContent<any>>}
       */
      const cs = []
      /**
       * Process one piece of (possibly attributed) content — the shared op-emission and format
       * state machine. The renderer path feeds it every piece produced by `readContent`; the
       * generic path feeds it `ContentFormat` markers only (the state machine must see every
       * marker, attributed or not — a plain marker can e.g. close a formerly attributed range)
       * and emits ops for plain content directly.
       * @param {AttributedContent<any>} c
       */
      const processContent = c => {
        // render (attributed) content even if it was deleted
        const renderContent = c.render && (!c.deleted || c.attrs != null)
        // content that was just deleted. It is not rendered as an insertion, because it doesn't
        // have any formats.
        const renderDelete = c.render && c.deleted
        // existing content that should be retained, only adding changed formats
        const retainContent = !c.render && (!c.deleted || c.attrs != null)
        const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined
        switch (c.content.constructor) {
          case ContentDeleted: {
            // fresh (mode 3) content renders as nothing here: the consuming state never saw it,
            // and gc'd content cannot render as an insert — a delete op would misapply
            if (renderDelete && !c.fresh) d.delete(c.content.getLength())
            break
          }
          case ContentString:
            if (renderContent) {
              if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) {
                // a retain expresses the format *diff* against existing (cached) content, so use
                // `changedFormats`: a format removed this change (e.g. its marker was deleted)
                // is present there as a `null` clear, whereas `currentFormats` (absolute) can
                // only re-assert present formats and would silently keep a stale one.
                // (`c.fresh` content is exempt from `retainDeletes`: it was inserted *and*
                // deleted by this very change, so the consuming state holds nothing to retain —
                // it renders as an insert below, carrying its attribution.)
                d.useFormats(changedFormats)
                usingChangedFormats = true
                // change render: a retained item with no attribution means its attribution was
                // removed → emit a clear rather than `{}` (skip). Present attribution merges.
                d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? clearedOwnAttribution())
              } else {
                d.useFormats(currentFormats)
                usingCurrentFormats = true
                d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution)
              }
            } else if (renderDelete) {
              d.delete(c.content.getLength())
            } else if (retainContent) {
              d.useFormats(changedFormats)
              usingChangedFormats = true
              d.retain(c.content.getLength())
            }
            break
          case ContentEmbed:
          case ContentAny:
          case ContentJSON:
          case ContentType:
          case ContentBinary:
          case ContentDoc:
            if (renderContent) {
              if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) {
                // a retain expresses the format *diff* → use `changedFormats` (see ContentString)
                d.useFormats(changedFormats)
                usingChangedFormats = true
                if (c.deleted && c.content.constructor === ContentType) {
                  // @todo use current transaction instead
                  d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null)
                } else if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) {
                  // a de/re-attributed node the renderer still claims (e.g. a partial accept):
                  // merge/clear its own attribution and descend so the nested walk heals exactly
                  // the child ids in `itemsToRender` — other children keep their attribution
                  d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? clearedOwnAttribution())
                } else {
                  d.retain(c.content.getLength(), undefined, attribution ?? clearedOwnAttribution())
                }
              } else if (deep && c.content.constructor === ContentType) {
                d.useFormats(currentFormats)
                usingCurrentFormats = true
                // a positive insert carries the node's full attributed state (see the `setAttr`
                // case in `nodeMapGetDelta`) — never the change-scoped opts
                d.insert([/** @type {any} */(c.content).type.toDelta({ renderer, deep: true })], undefined, attribution)
              } else {
                d.useFormats(currentFormats)
                usingCurrentFormats = true
                d.insert(c.content.getContent(), undefined, attribution)
              }
            } else if (renderDelete) {
              d.delete(1)
            } else if (retainContent) {
              if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) {
                // @todo use current transaction instead
                d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll))
              } else {
                d.useFormats(changedFormats)
                usingChangedFormats = true
                d.retain(1)
              }
            }
            break
          case ContentFormat: {
            const { key, value } = /** @type {ContentFormat} */ (c.content)
            const currFormatVal = currentFormats[key] ?? null
            if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedFormats, key))) {
              previousUnattributedFormats[key] = c.deleted ? value : currFormatVal
            }
            // @todo write a function "updateCurrentFormats" and "updateChangedFormats"
            // # Update Formats
            if (renderContent || renderDelete) {
              // create fresh references
              if (usingCurrentFormats) {
                currentFormats = object.assign({}, currentFormats)
                usingCurrentFormats = false
              }
              if (usingChangedFormats) {
                usingChangedFormats = false
                changedFormats = object.assign({}, changedFormats)
              }
            }
            if (renderContent || renderDelete) {
              if (itemsToRender !== null && renderer !== null) {
                // only attributing change renders consult this (see the reset branch below)
                if (renderedFormatKeys === null) renderedFormatKeys = new Set()
                renderedFormatKeys.add(key)
              }
              // `previousFormats` tracks the value governing the current walk position in the
              // *consuming cache state*, so the retain diff below stays a plain cache → new-state
              // comparison. Contributors: markers deleted by this change in event renders (their
              // value governed the cache until now; heal-rendered deletes are PRE-deleted markers
              // whose removal the cache already rendered — they must not contribute), alive
              // markers re-rendered by heal renders (`retainInserts` — their value was in the
              // cache), and retained alive markers (the branch below). A gated update here is not
              // enough: a marker deleted by the change whose value coincides with the walk's
              // current value would silently drop out of the cache-reference tracking, and a later
              // marker would be misread as a restore-to-previous, swallowing a needed format diff.
              if (c.deleted) {
                if (itemsToRender !== null && !retainInserts) previousFormats[key] = value
              } else { // !c.deleted
                if (retainInserts) previousFormats[key] = value
                if (value == null) {
                  delete currentFormats[key]
                } else {
                  currentFormats[key] = value
                }
                currentFormatsAttributed[key] = false
              }
              // the retain diff for following spans is exactly cache (previousFormats) → new
              // state (currentFormats), recomputed per key at every rendered marker
              if (equalFormats(currentFormats[key] ?? null, previousFormats[key] ?? null)) {
                delete changedFormats[key]
              } else {
                changedFormats[key] = currentFormats[key] ?? null
              }
            } else if (retainContent && !c.deleted) {
              // fresh reference to currentFormats only
              if (usingCurrentFormats) {
                currentFormats = object.assign({}, currentFormats)
                usingCurrentFormats = false
              }
              if (usingChangedFormats && changedFormats[key] !== undefined) {
                usingChangedFormats = false
                changedFormats = object.assign({}, changedFormats)
              }
              if (value == null) {
                delete currentFormats[key]
              } else {
                currentFormats[key] = value
              }
              currentFormatsAttributed[key] = false
              delete changedFormats[key]
              previousFormats[key] = value
            }
            // # Update Attributions
            // A format marker deleted in a change render under an *attributing* renderer nets to no
            // attribution (its insert+delete suggestion cancels → `attribution == null`), yet it ends
            // the attributed range it opened: the following retained content must drop the stale
            // `{ format: { [key]: [] } }` the marker's insertion wrote to the cache. Emit an explicit
            // `null` leaf for the key (a context-wide `useAttribution(null)` cannot carry a per-key
            // clear). Conditions: only an attributing render (`renderer !== null`; without a
            // renderer there are no attributions to clear), and only when the deletion reverts to
            // a value that is not itself governed by a pending suggestion: either no value at all
            // (`currFormatVal == null`) or a *committed* enclosing value (provenance tracked in
            // `currentFormatsAttributed` — e.g. an accept/reject deleting a marker re-exposes the
            // enclosing committed `bold:true`, and the span's stale suggested-format attribution
            // must be cleared). If it reverts to a still-pending *attributed* value (deleting a
            // `bold:null` marker re-exposes an enclosing attributed `bold:true`, as when
            // re-bolding), the attribution is preserved. Suppressed entirely while an attributed
            // same-key range is open (`previousUnattributedFormats` has the key): there the fresh
            // render skips this marker — its governance belongs to the still-open attributed
            // range, whose ambient context re-stamps the correct attribution on the following
            // retains — mirroring the identical guard on `isAcceptedFormatClear`.
            const isDeletedFormatClear = attribution == null && renderer !== null && renderDelete && c.deleted && itemsToRender != null && (currFormatVal == null || currentFormatsAttributed[key] !== true) && !equalFormats(value, currFormatVal) && !object.hasProperty(previousUnattributedFormats, key)
            // The alive-marker analogue of `isDeletedFormatClear`: a format marker whose
            // *attribution* was removed while the marker survives — its suggestion was accepted
            // (the marker's id arrives via the renderer's `'change'` event). The original change
            // render stamped `{ format: { [key]: [] } }` on the spans this marker governs, so the
            // heal render must emit a per-key `null` leaf over those spans (a blunt
            // `attribution: null` would also wipe unrelated attributions such as a co-located
            // `{ delete: [] }`). Gated on `retainInserts` — the diff-against-existing-content
            // mode used by attribution-heal renders: in a plain event render an unattributed
            // alive marker is just new unattributed content and must not emit spurious clears.
            // When the key is tracked as an open attributed range (`previousUnattributedFormats`)
            // the accepted marker instead defers to the regular branch chain below, which mirrors
            // the fresh render exactly: mid-range it *keeps* the ambient attribution (relative to
            // the base doc the span's format is still a suggested change), and at the range end
            // (`sameAsPreviousAttributions`) it closes the attribution context.
            const isAcceptedFormatClear = attribution == null && renderer !== null && renderContent && !c.deleted && itemsToRender != null && retainInserts && !object.hasProperty(previousUnattributedFormats, key)
            if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || object.hasProperty(previousUnattributedFormats, key)) {
              /**
                 * @type {Attribution}
                 */
              const formattingAttribution = object.assign({}, d.usedAttribution)
              const changedAttributedFormats = /** @type {{ [key: string]: Array<any>|null }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {}))
              const sameAsPreviousAttributions = equalFormats(previousUnattributedFormats[key], currentFormats[key] ?? null)
              // An *unattributed* marker rendered by this change (e.g. a fresh base-doc marker)
              // that closes an open attributed same-key range by restoring the previous
              // unattributed value: the spans it governs must drop their stale attribution from
              // the cache, exactly like the attributed range-end below. Mirrors the fresh render
              // precisely: there, the reset only closes the ambient attribution when removing the
              // key leaves the format context empty (the `useAttribution(null)` case below) —
              // while another attributed key holds the context open, the removal happens on a
              // copy that is never installed, so the key survives on the following spans and the
              // change render must NOT clear it.
              const isRangeEndClear = attribution == null && sameAsPreviousAttributions && renderContent && !c.deleted && itemsToRender != null && renderer !== null && object.hasProperty(previousUnattributedFormats, key) && object.every(changedAttributedFormats, (_v, k) => k === key)
              if (isDeletedFormatClear || (isAcceptedFormatClear && changedAttributedFormats[key] == null)) {
                // uniformly emit the per-key clear — also for a marker that *ends* a formerly
                // attributed range: the attributed render emits a trailing per-key `null` past
                // the closing marker as well (see the `attribution != null` case below), so the
                // heal render mirrors that exact span; where the cache holds no stale
                // attribution the clear merges as a no-op.
                changedAttributedFormats[key] = null
                delete previousUnattributedFormats[key]
              } else if (isAcceptedFormatClear) {
                // accepted marker inside a *still-attributed* (pending) range for the same key —
                // the ambient context holds an attributor list, not a clear. Relative to the base
                // doc the effective format of this span is still a suggested change, so the fresh
                // render keeps attributing it (the `skip` case below): keep the ambient
                // attribution rather than clearing it.
              } else if (attribution == null && !sameAsPreviousAttributions) {
                // skip
              } else if (attribution == null || sameAsPreviousAttributions) {
                // an unattributed format was found or an attributed format
                // was found that resets to the previous status. When this format item is
                // itself rendered this transaction (`renderContent || renderDelete`) in a change/diff
                // render (`itemsToRender != null`), it is the END of an attributed format range:
                // emit an explicit clear (a `null` leaf) so the retained content drops any stale
                // `{ format: { [key]: [] } }` from the maintained `delta` cache — a bare context-skip
                // (`delete`) would leave it in place. The same applies to a *retained* attributed
                // boundary when a rendered same-key marker preceded it in this walk
                // (`renderedFormatKeys`): the boundary only resets-to-previous *because of* the
                // rendered change (e.g. a base-doc format arriving under an equal same-key format
                // suggestion — which marker comes first depends on client ids), so the spans it
                // governs must drop their now-stale attribution too. For a merely-retained boundary
                // with no rendered same-key marker, or a full insert render (removal is already
                // modeled as absence in `currentFormats`), just drop the key: a change render must
                // not emit ops for unchanged ranges, and inserts must stay free of a spurious
                // `{ format: { [key]: null } }`.
                // `c.fresh`: a marker inserted (and possibly auto-deleted) by this very change —
                // e.g. a base-doc format clear arriving inside a suggestion-deleted paragraph —
                // stays on the retained path (mode 0, the fresh-format exemption), but when it
                // resets-to-previous it is the range END of the attributed run in the new state
                // and must clear the stale attribution from the cache like a rendered marker.
                if (isRangeEndClear || (attribution != null && itemsToRender != null && (renderContent || renderDelete || c.fresh || renderedFormatKeys?.has(key) === true))) {
                  changedAttributedFormats[key] = null
                } else {
                  delete changedAttributedFormats[key]
                  // pending per-key clears force this copy to install (the all-null disjunct
                  // below); installing after silently dropping this key would close its
                  // governance without the clear the fresh render's context-close implies,
                  // leaving a stale `{ format: { [key]: [] } }` on the following retained
                  // spans — re-add the key as an explicit clear. Inert outside attributing
                  // change renders: null leaves cannot exist in fresh contexts.
                  if (itemsToRender != null && !object.isEmpty(changedAttributedFormats) && object.every(changedAttributedFormats, v => v == null)) {
                    changedAttributedFormats[key] = null
                  }
                }
                delete previousUnattributedFormats[key]
              } else {
                // an attributed marker opens pending governance for the key — a deleted attributed
                // marker never writes `currentFormats`, hence the alive gate; an attributed marker
                // that value-equals the previous unattributed value takes the reset branch above
                // and correctly yields committed governance
                if (!c.deleted) currentFormatsAttributed[key] = true
                const by = changedAttributedFormats[key] = (changedAttributedFormats[key]?.slice() ?? [])
                by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
                const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt)
                if (attributedAt) formattingAttribution.formatAt = attributedAt
              }
              if (object.isEmpty(changedAttributedFormats)) {
                d.useAttribution(null)
              } else if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || isRangeEndClear || (itemsToRender != null && object.every(changedAttributedFormats, v => v == null))) {
                // the last disjunct: a change-render copy whose remaining leaves are ALL per-key
                // null clears is the analogue of the fresh render's empty copy (fresh contexts
                // never contain null leaves — they are produced only by the change-render-only
                // predicates above) — install it, so an unattributed close marker's key deletion
                // closes the governance while the pending cache clears still propagate; discarding
                // it would re-merge the stale key into the cache from the surviving ambient
                const attributedAt = (c.deleted ? attribution?.deleteAt : attribution?.insertAt)
                if (attributedAt != null) formattingAttribution.formatAt = attributedAt
                d.useAttribution(formattingAttribution)
              }
            }
            break
          }
        }
      }
      for (let item = this._start; item !== null; item = item.right) {
        const content = item.content
        if (renderer === null || !renderer.hasItem(item)) {
          // generic fast path: content the renderer doesn't claim renders as-is — no attribution
          // lookups, no AttributedContent wrappers and, in the common full-coverage case, no
          // content slicing
          if (content.constructor === ContentFormat) {
            // format markers always flow through the shared state machine
            processContent(new AttributedContent(content, item.id.clock, item.deleted, null,
              itemsToRender == null ? 1 : (itemsToRender.has(item.id.client, item.id.clock) ? 2 : 0)))
          } else if (item.deleted) {
            // plain deleted content is invisible; in a change render, the ranges deleted by this
            // change emit `delete` ops — position-only, the content itself is not needed
            if (itemsToRender !== null && itemsToRender.intersects(item.id.client, item.id.clock, item.length)) {
              const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length)
              for (let ir = 0; ir < rslice.length; ir++) {
                const idrange = rslice[ir]
                if (idrange.exists) {
                  // mirror the piece-wise op sizes of the renderer path: string-ish content
                  // deletes by length, other content deletes one element per piece
                  d.delete((content.constructor === ContentString || content.constructor === ContentDeleted) ? idrange.len : 1)
                }
              }
            }
          } else if (itemsToRender == null) {
            if (retainInserts) {
              // attribution-overlay render (e.g. `toDelta({ renderer, retainInserts: true })`):
              // existing content is retained, clearing any formerly cached own-attribution
              d.useFormats(changedFormats)
              usingChangedFormats = true
              d.retain(content.getLength(), undefined, clearedOwnAttribution())
            } else {
              // full render: a plain insert of the whole item
              d.useFormats(currentFormats)
              usingCurrentFormats = true
              if (deep && content.constructor === ContentType) {
                d.insert([/** @type {any} */(content).type.toDelta({ renderer, deep: true })]) // full state, see above
              } else if (content.constructor === ContentString) {
                d.insert(/** @type {ContentString} */ (content).str)
              } else {
                d.insert(content.getContent())
              }
            }
          } else {
            // change render on alive plain content: inserted ranges become inserts, everything
            // else retains (position-only). `retainInserts` (attribution-heal renders) retains
            // previously inserted content while clearing its former attribution.
            const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length)
            let itemContent = rslice.length > 1 ? content.copy() : content
            for (let ir = 0; ir < rslice.length; ir++) {
              const idrange = rslice[ir]
              const c = itemContent
              if (ir !== rslice.length - 1) {
                itemContent = itemContent.splice(idrange.len)
              }
              if (!idrange.exists) {
                if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) {
                  // @todo use current transaction instead
                  d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll))
                } else {
                  d.useFormats(changedFormats)
                  usingChangedFormats = true
                  // mirror the piece-wise op sizes of the renderer path (see the delete branch)
                  d.retain(content.constructor === ContentString ? idrange.len : 1)
                }
              } else if (retainInserts) {
                if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) {
                  // a de/re-attributed node (e.g. its insert-suggestion was accepted): clear its
                  // own attribution and descend so the nested walk heals exactly the child ids
                  // present in `itemsToRender` — children outside the change set keep their
                  // attribution (id-scoped, never a blanket subtree clear)
                  d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll), undefined, clearedOwnAttribution())
                } else {
                  d.useFormats(changedFormats)
                  usingChangedFormats = true
                  d.retain(idrange.len, undefined, clearedOwnAttribution())
                }
              } else {
                d.useFormats(currentFormats)
                usingCurrentFormats = true
                if (deep && content.constructor === ContentType) {
                  d.insert([/** @type {any} */(content).type.toDelta({ renderer, deep: true })]) // full state, see above
                } else if (content.constructor === ContentString) {
                  d.insert(/** @type {ContentString} */ (c).str)
                } else {
                  d.insert(c.getContent())
                }
              }
            }
          }
        } else {
          cs.length = 0
          if (itemsToRender != null) {
            const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length)
            // fresh content deleted by the very change being rendered gets mode 3: it renders as
            // an insert even under `retainDeletes` — the consuming state (e.g. the maintained
            // `delta` cache) has never seen it, so there is nothing to retain. Freshness is
            // decided per id range, NOT per item: structs merge across cleanups (e.g. a queued
            // transaction's render runs after an earlier finally-block merged its fresh item into
            // an older neighbor), so one item can span fresh and pre-existing ids. ContentFormat
            // markers are exempt and keep mode 0 (the retained-marker path; they never merge, so
            // the whole-item check is exact): the format state machine treats fresh-deleted
            // markers exactly like before this change rendered.
            const checkFresh = item.deleted && insertedItems !== null && content.constructor !== ContentFormat
            const freshFormat = item.deleted && insertedItems !== null && content.constructor === ContentFormat && insertedItems.hasId(item.id)
            let itemContent = rslice.length > 1 ? content.copy() : content
            for (let ir = 0; ir < rslice.length; ir++) {
              const idrange = rslice[ir]
              let c = itemContent
              if (ir !== rslice.length - 1) {
                itemContent = itemContent.splice(idrange.len)
              }
              if (!idrange.exists || !checkFresh) {
                renderer.readContent(cs, item.id.client, idrange.clock, item.deleted, c,
                  idrange.exists ? (freshFormat ? 0 : 2) : 0)
              } else {
                // an exists range may itself straddle fresh and pre-existing ids (ranges from
                // different sources merge in `itemsToRender`) — split it against `insertedItems`
                const fslice = insertedItems.slice(item.id.client, idrange.clock, idrange.len)
                let subContent = fslice.length > 1 ? c.copy() : c
                for (let fi = 0; fi < fslice.length; fi++) {
                  const frange = fslice[fi]
                  c = subContent
                  if (fi !== fslice.length - 1) {
                    subContent = subContent.splice(frange.len)
                  }
                  renderer.readContent(cs, item.id.client, frange.clock, item.deleted, c, frange.exists ? 3 : 2)
                }
              }
            }
            if (freshFormat) {
              // fresh format markers stay on the retained path (mode 0, see `checkFresh`), but
              // the format state machine must still see their freshness: a fresh clear marker
              // that resets-to-previous ends an attributed range and must emit the per-key
              // attribution clear (`cs` is reset per item; a freshFormat item is a single
              // len-1 ContentFormat, so this marks exactly its pieces)
              for (let i = 0; i < cs.length; i++) { cs[i].fresh = true }
            }
          } else {
            renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, content, 1)
          }
          for (let i = 0; i < cs.length; i++) {
            processContent(cs[i])
          }
        }
      }
    }
    return /** @type {any} */ (d.done(false))
  }

  /**
   * Render the difference to another ydoc (which can be empty) and highlight the differences with
   * attributions.
   *
   * @param {Object} [opts]
   * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
   * @return {delta.Delta<DConf>}
   */
  toDeltaDeep (opts = {}) {
    return /** @type {any} */ (this.toDelta({ ...opts, deep: true }))
  }

  /**
   * Apply a {@link Delta} on this shared type.
   *
   * @param {delta.DeltaAny} d The changes to apply on this element.
   * @param {any} [origin] Origin of the transaction that applies this delta (stored on
   * `transaction.origin` and forwarded verbatim on the emitted `'delta'` event, so listeners can
   * recognize — and skip — changes they produced themselves; see the lib0 `RDT` spec). Defaults to `null`.
   * @param {Object} [opts]
   * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
   * @return {delta.DeltaBuilder<any>?} The lib0 `RDT` "fix" of this apply — a change measured against the
   * caller's expected state (`old.apply(d)`) that transforms it into the actual state, or `null`
   * when `d` applied cleanly. A fix is produced when `d` (or a nested `modify`/`modifyAttr`)
   * addresses a *deleted but rendered* node (e.g. a suggestion-deleted paragraph under a
   * DiffRenderer): that part of the change is not applied to the document, and its inverse
   * (`lib0/delta` `inverse` against the node's rendered state) is returned — the change is
   * immediately reverted.
   *
   * @public
   */
  applyDelta (d, origin = null, { renderer = this._renderer } = {}) {
    if (d.isEmpty()) return null
    if (this.doc == null) {
      (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d)
      return null
    }
    const titem = this._item
    if (titem !== null && titem.deleted) {
      if (rendererContentLength(renderer, titem) > 0) {
        // deleted, but still rendered (e.g. a suggestion-deleted node): apply nothing — revert the
        // whole change and return its inverse (against the rendered state the caller addressed)
        const inv = delta.inverse(d, /** @type {any} */ (this.toDeltaDeep({ renderer })))
        return inv.isEmpty() ? null : /** @type {any} */ (inv)
      }
      return null // invisible deleted type: the caller's view shows nothing here — silently drop
    }
    // @todo this was moved here from ytext. Make this more generic
    return transact(this.doc, transaction => {
      /**
       * The accumulated fix. Its coordinates live in the caller's *expected* space, so they are
       * tracked from `d`'s own ops (`expectedIndex`) — not `currPos.index`, which also counts
       * content that a delete over attributed-deleted ranges leaves rendered.
       *
       * @type {delta.DeltaBuilder<any>?}
       */
      let fix = null
      let fixLen = 0
      let expectedIndex = 0
      /**
       * @param {delta.DeltaAny?} childFix
       * @param {{ [k:string]: any }} [invFormat]
       */
      const appendModifyFix = (childFix, invFormat) => {
        const f = fix ?? (fix = /** @type {any} */ (delta.create()))
        expectedIndex > fixLen && f.retain(expectedIndex - fixLen)
        f.modify(/** @type {any} */ (childFix ?? delta.create().done(false)), invFormat)
        fixLen = expectedIndex + 1
      }
      const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), renderer)
      for (const op of d.children) {
        if (delta.$textOp.check(op)) {
          insertContent(transaction, /** @type {any} */ (this), currPos, new ContentString(op.insert), op.format || {})
          expectedIndex += op.length
        } else if (delta.$insertOp.check(op)) {
          insertContentHelper(transaction, this, currPos, op.insert, op.format || {})
          expectedIndex += op.length
        } else if (delta.$retainOp.check(op)) {
          currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {})
          expectedIndex += op.length
        } else if (delta.$deleteOp.check(op)) {
          deleteText(transaction, currPos, op.delete)
        } else if (delta.$modifyOp.check(op)) {
          let item = currPos.right
          while (item !== null && rendererContentLength(renderer, item) === 0) { item = item.right }
          if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() }
          if (item.deleted) {
            // deleted but rendered: revert instead of apply. Advance the cursor first (populating
            // `currentFormats` with any markers up to the node) without applying `op.format`, then
            // recurse — the child's deleted-guard applies nothing and returns the inverse.
            currPos.formatText(transaction, /** @type {any} */ (this), 1, {})
            /** @type {{ [k:string]: any }|undefined} */
            let invFormat
            for (const k in op.format) {
              (invFormat ?? (invFormat = {}))[k] = currPos.currentFormats.get(k) ?? null
            }
            const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer })
            if (childFix !== null || invFormat !== undefined) {
              appendModifyFix(childFix, invFormat)
            }
          } else {
            const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer })
            currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {})
            if (childFix !== null) {
              appendModifyFix(childFix)
            }
          }
          expectedIndex += 1
        } else {
          error.unexpectedCase()
        }
      }
      for (const op of d.attrs) {
        if (delta.$setAttrOp.check(op)) {
          nodeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value)
        } else if (delta.$deleteAttrOp.check(op)) {
          nodeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key))
        } else {
          // modifyAttr — locate the target renderer-aware: a deleted map value may still be rendered
          const mapItem = this._map.get(/** @type {any} */ (op.key))
          const sub = mapItem === undefined
            ? undefined
            : (mapItem.deleted
                ? (mapItem.content.constructor === ContentType && rendererContentLength(renderer, mapItem) > 0
                    ? /** @type {ContentType} */ (mapItem.content).type
                    : undefined)
                : mapItem.content.getContent()[mapItem.length - 1])
          if (!(sub instanceof YNode)) {
            error.unexpectedCase()
          }
          const subFix = sub.applyDelta(op.value, origin, { renderer })
          if (subFix !== null) {
            const f = fix ?? (fix = /** @type {any} */ (delta.create()))
            f.modifyAttr(/** @type {any} */ (op.key), /** @type {any} */ (subFix))
          }
        }
      }
      return fix !== null && !(/** @type {delta.DeltaBuilder<any>} */ (fix).done(false).isEmpty()) ? fix : null
    }, origin)
  }

  /**
   * Makes a copy of this data type that can be included somewhere else.
   *
   * Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
   *
   * @return {YNode<DConf>}
   */
  clone () {
    const cpy = this._copy()
    cpy.applyDelta(this.toDeltaDeep())
    return cpy
  }

  /**
   * Removes all elements from this YMap.
   */
  clearAttrs () {
    const d = delta.create()
    this.forEachAttr((_, key) => {
      d.deleteAttr(/** @type {any} */ (key))
    })
    this.applyDelta(d)
  }

  /**
   * Removes an attribute from this YXmlElement.
   *
   * @param {string} attributeName The attribute name that is to be removed.
   *
   * @public
   */
  deleteAttr (attributeName) {
    this.applyDelta(delta.create().deleteAttr(attributeName).done())
  }

  /**
   * Sets or updates an attribute.
   *
   * @template {Exclude<keyof delta.DeltaConfGetAttrs<DConf>,symbol>} KEY
   * @template {delta.DeltaConfGetAttrs<DConf>[KEY]} VAL
   *
   * @param {KEY} attributeName The attribute name that is to be set.
   * @param {VAL} attributeValue The attribute value that is to be set.
   * @return {VAL}
   *
   * @public
   */
  setAttr (attributeName, attributeValue) {
    this.applyDelta(delta.create().setAttr(attributeName, attributeValue).done())
    return attributeValue
  }

  /**
   * Returns an attribute value that belongs to the attribute name.
   *
   * @template {Exclude<keyof delta.DeltaConfGetAttrs<DConf>,symbol|number>} KEY
   * @param {KEY} attributeName The attribute name that identifies the queried value.
   * @return {delta.DeltaConfGetAttrs<DConf>[KEY]|undefined} The queried attribute value.
   * @public
   */
  getAttr (attributeName) {
    return /** @type {any} */ (nodeMapGet(this, attributeName))
  }

  /**
   * Returns whether an attribute exists
   *
   * @param {string} attributeName The attribute name to check for existence.
   * @return {boolean} whether the attribute exists.
   *
   * @public
   */
  hasAttr (attributeName) {
    return /** @type {any} */ (nodeMapHas(this, attributeName))
  }

  /**
   * Returns all attribute name/value pairs in a JSON Object.
   *
   * @param {Snapshot} [snapshot]
   * @return {{ [Key in Extract<keyof delta.DeltaConfGetAttrs<DConf>,string>]?: delta.DeltaConfGetAttrs<DConf>[Key]}} A JSON Object that describes the attributes.
   *
   * @public
   */
  getAttrs (snapshot) {
    return /** @type {any} */ (snapshot ? nodeMapGetAllSnapshot(this, snapshot) : nodeMapGetAll(this))
  }

  /**
   * Inserts new content at an index.
   *
   * Important: This function expects an array of content. Not just a content
   * object. The reason for this "weirdness" is that inserting several elements
   * is very efficient when it is done as a single operation.
   *
   * @example
   *  // Insert character 'a' at position 0
   *  yarray.insert(0, ['a'])
   *  // Insert numbers 1, 2 at position 1
   *  yarray.insert(1, [1, 2])
   *
   * @param {number} index The index to insert content at.
   * @param {Array<delta.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} content Array of content to append.
   * @param {delta.Formats} [format]
   */
  insert (index, content, format) {
    this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content), format).done())
  }

  /**
   * Inserts new content at an index.
   *
   * Important: This function expects an array of content. Not just a content
   * object. The reason for this "weirdness" is that inserting several elements
   * is very efficient when it is done as a single operation.
   *
   * @example
   *  // Insert character 'a' at position 0
   *  yarray.insert(0, ['a'])
   *  // Insert numbers 1, 2 at position 1
   *  yarray.insert(1, [1, 2])
   *
   * @param {number} index The index to insert content at.
   * @param {number} length The index to insert content at.
   * @param {delta.Formats} formats
   *
   */
  format (index, length, formats) {
    this.applyDelta(delta.create().retain(index).retain(length, formats))
  }

  /**
   * Appends content to this YArray.
   *
   * @param {Array<delta.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} content Array of content to append.
   *
   * @todo Use the following implementation in all types.
   */
  push (content) {
    this.insert(this.length, content)
  }

  /**
   * Prepends content to this YArray.
   *
   * @param {delta.DeltaConfGetText<DConf>} content Array of content to prepend.
   */
  unshift (content) {
    this.insert(0, content)
  }

  /**
   * Deletes elements starting from an index.
   *
   * @param {number} index Index at which to start deleting elements
   * @param {number} length The number of elements to remove. Defaults to 1.
   */
  delete (index, length = 1) {
    this.applyDelta(delta.create().retain(index).delete(length))
  }

  /**
   * Returns the i-th element from a YArray.
   *
   * @param {number} index The index of the element to return from the YArray
   * @return {delta.DeltaConfGetChildren<DConf>}
   */
  get (index) {
    return typeListGet(this, index)
  }

  /**
   * Returns a portion of this YXmlFragment into a JavaScript Array selected
   * from start to end (end not included).
   *
   * @param {number} [start]
   * @param {number} [end]
   * @return {Array<delta.DeltaConfGetChildren<DConf>>}
   */
  slice (start = 0, end = this.length) {
    return typeListSlice(this, start, end)
  }

  /**
   * @todo refactor this, this should use getContent only!
   *
   * Transforms this YArray to a JavaScript Array.
   *
   * @return {Array<delta.DeltaConfGetChildren<DConf> | delta.DeltaConfGetText<DConf>>}
   */
  toArray () {
    const dcontent = this.toDelta()
    /**
     * @type {Array<any>}
     */
    const children = []
    for (const child of dcontent.children) {
      if (delta.$insertOp.check(child)) {
        for (let i = 0; i < child.insert.length; i++) {
          children.push(child.insert[i])
        }
      } else if (delta.$textOp.check(child)) {
        children.push(child.insert)
      }
    }
    return children
  }

  /**
   * Transforms this Shared Type to a JSON object.
   * @return {{ name?: string, attrs?: { [K:string|number]: any }, children?: Array<any>  }}
   */
  toJSON () {
    /**
     * @type {{[K:string]:any}}
     */
    const attrs = this.getAttrs()
    for (const k in attrs) {
      const attr = attrs[k]
      attrs[k] = attr instanceof YNode ? attr.toJSON() : attr
    }
    const children = this.toArray().map(child => child instanceof YNode ? /** @type {any} */ (child.toJSON()) : child)
    /**
     * @type {any}
     */
    const res = {}
    if (this.name != null) {
      res.name = this.name
    }
    if (this.length > 0) {
      res.children = children
    }
    if (this.attrSize > 0) {
      res.attrs = attrs
    }
    return res
  }

  /**
   * @param {object} opts
   * @param {boolean} [opts.forceTag] enforce creating a surrouning <name /> tag, even if it is null.
   */
  toString ({ forceTag = false } = {}) {
    /**
     * @type {Array<[string|number,string]>}
     */
    const attrs = []
    this.forEachAttr((attr, key) => {
      attrs.push([(key), /** @type {any} */ (attr) instanceof YNode ? attr.toString({ forceTag: true }) : JSON.stringify(attr)])
    })
    const attrsString = (attrs.length > 0 ? ' ' : '') + attrs.sort((a, b) => a[0].toString() < b[0].toString() ? -1 : 1).map(attr => attr[0] + '=' + attr[1]).join(' ')
    /**
     * @type {string}
     */
    const children = this.toArray().map(c => s.$string.check(c) ? c : (c instanceof YNode ? c.toString({ forceTag: true }) : JSON.stringify(c))).join('')
    if (this.name == null && !forceTag && attrs.length === 0) {
      return children
    }
    if (this.length === 0) {
      return `<${this.name ?? ''}${attrsString} />`
    }
    return `<${this.name ?? ''}${attrsString}>${children}</${this.name ?? ''}>`
  }

  /**
   * Returns an Array with the result of calling a provided function on every
   * child-element.
   *
   * @template M
   * @param {(child:delta.DeltaConfGetChildren<DConf>|delta.DeltaConfGetText<DConf>,index:number)=>M} f Function that produces an element of the new Array
   * @return {Array<M>} A new array with each element being the result of the
   *                 callback function
   */
  map (f) {
    return this.toArray().map(f)
  }

  /**
   * Executes a provided function once on every element of this YArray.
   *
   * @param {(child:delta.DeltaConfGetChildren<DConf>|delta.DeltaConfGetText<DConf>,index:number)=>any} f Function that produces an element of the new Array
   */
  forEach (f) {
    return this.toArray().forEach(f)
  }

  /**
   * Executes a provided function on once on every key-value pair.
   *
   * @param {(val:delta.DeltaConfGetAttrs<DConf>[any],key:Exclude<keyof delta.DeltaConfGetAttrs<DConf>,symbol>,ynode:this)=>any} f
   */
  forEachAttr (f) {
    this._map.forEach((item, key) => {
      if (!item.deleted) {
        f(item.content.getContent()[item.length - 1], /** @type {any} */ (key), this)
      }
    })
  }

  /**
   * Returns the keys for each element in the YMap Type.
   *
   * @return {IterableIterator<import('lib0/ts').KeyOf<delta.DeltaConfGetAttrs<DConf>>>}
   */
  attrKeys () {
    return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[0])
  }

  /**
   * Returns the values for each element in the YMap Type.
   *
   * @return {IterableIterator<delta.DeltaConfGetAttrs<DConf>[any]>}
   */
  attrValues () {
    return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
  }

  /**
   * Returns an Iterator of [key, value] pairs
   *
   * @return {IterableIterator<{ [K in keyof delta.DeltaConfGetAttrs<DConf>]: [K,delta.DeltaConfGetAttrs<DConf>[K]] }[any]>}
   */
  attrEntries () {
    return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]]))
  }

  /**
   * Returns the number of stored attributes (count of key/value pairs)
   *
   * @return {number}
   */
  get attrSize () {
    return [...createMapIterator(this)].length
  }

  /**
   * @param {this} other
   */
  [traits.EqualityTraitSymbol] (other) {
    return this.toDelta().equals(other.toDelta())
  }

  /**
   * @todo this doesn't need to live in a method.
   *
   * Transform the properties of this type to binary and write it to an
   * BinaryEncoder.
   *
   * This is called when this Item is sent to a remote peer.
   *
   * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
   */
  _write (encoder) {
    encoder.writeTypeRef(this._legacyTypeRef)
    switch (this._legacyTypeRef) {
      case YXmlElementRefID:
      case YXmlHookRefID: {
        encoder.writeKey(this.name)
        break
      }
    }
  }
}

/**
 * Schema of a {@link YNode} with any delta configuration. This is where the nominal `y:node` tag
 * lives - {@link $node} builds on it. Mirrors lib0's `$deltaAny` / `$delta` split.
 */
export const $nodeAny = /** @type {s.Schema<YNode<any>>} */ (YNode.prototype.$type = s.$type('y:node', YNode))

/**
 * Schema of a {@link YNode} with a specific delta configuration.
 *
 * **Checked at runtime:** the nominal type, and the node's `name`. `name` is assigned once in the
 * constructor and never reassigned, so the check is stable for the node's lifetime.
 *
 * **Not checked at runtime:** `attrs`, `children`, `text`, `formats`. A node does not carry its
 * `DConf` - `name` is its only runtime residue, which is why {@link YNode#$delta} returns
 * `$deltaAny`. Those parts are a type-level assertion, and they could not be checked soundly
 * anyway: a node is a *concurrently* mutable container, so a check of its content would hold only
 * for the instant it ran - a remote update can violate it immediately after, with no local code
 * involved. To validate content, validate the immutable value instead:
 * `delta.$delta(conf).check(node.toDelta())`.
 *
 * This is stricter than lib0's `$Delta.check`, which *skips* the name check when `name == null`.
 * That is right for a delta (a nameless delta asserts nothing about the name) and wrong for a node
 * (a nameless node is definitively a fragment). So `$node({ name: 'p' })` rejects `ydoc.get('key')`,
 * whose `name` is `null` unless a name was passed as the *second* argument.
 *
 * Allocates a fresh schema per call - hoist it rather than calling it in a hot loop. For a cheap
 * nominal gate use {@link $nodeAny} (one identity compare).
 *
 * @template {delta.ReadableDeltaConf} DConf
 * @param {DConf} dconf
 * @return {s.Schema<YNode<delta.ReadDeltaConf<DConf>>>}
 */
export const $node = dconf => {
  // the same coercion lib0's `$delta` applies to `conf.name`
  const $name = dconf.name == null ? s.$any : s.$(dconf.name)
  return s.$custom(o => $nodeAny.check(o) && $name.check(o.name))
}

/**
 * Shared "render no attrs" marker for change-scoped `toDelta` calls on untouched types.
 *
 * @type {Set<string|null>}
 */
const emptyAttrSet = new Set()

/**
 * The types touched by a set of item ids, keyed by type, each with the attr keys changed on it
 * (`null` for its content children) — ancestor-closed up to the root, so it is the `modified` opt a
 * change-scoped {@link YNode#toDelta} wants for `items`.
 *
 * @param {StructStore} store
 * @param {IdSet} items
 * @return {Map<YNode,Set<string|null>>}
 */
export const computeModifiedFromItems = (store, items) => {
  /**
   * @type {Map<YNode,Set<null|string>>}
   */
  const modified = new Map()
  iterateStructsByIdSetWithoutSplits(store, items, /** @param {Item | GC | Skip | null} item */ item => {
    while (item instanceof Item) {
      const parent = /** @type {YNode} */ (item.parent)
      const conf = map.setIfUndefined(modified, parent, set.create)
      if (conf.has(item.parentSub)) break // has already been marked as modified
      conf.add(item.parentSub)
      item = parent._item
    }
  })
  return modified
}

/**
 * The active renderer's attribution overlay changed (e.g. a suggestion was accepted or rejected)
 * without a Y transaction on the type's doc, so no `YEvent` fires. Re-render the affected id
 * ranges and keep the RDT surface current, mirroring the per-transaction upkeep in
 * `cleanupTransactions`: patch the maintained {@link YNode#delta} cache and emit the change on
 * the `'delta'` channel. Wired to the renderer's `'change'` event by {@link YNode#useRenderer}.
 *
 * Ranges in `changes` that the doc has not (yet) integrated — e.g. a base-doc edit whose update
 * flows in only after the renderer event — simply render to nothing here; the later transaction
 * covers them through the regular event path.
 *
 * The cache patch must stay on the builder `apply` — see the {@link YNode#_delta}
 * fingerprint-memo invariant.
 *
 * @param {YNode<any>} type
 * @param {IdSet} changes
 * @param {any} origin
 *
 * @private
 * @function
 */
export const typeApplyRendererChange = (type, changes, origin) => {
  const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0
  // no deleted-type guard: this handler only exists while a custom renderer is attached (see
  // `useRenderer`), and a custom renderer may still render a deleted type — its cache and 'delta'
  // channel must stay current through accept/reject overlay changes.
  if ((type._delta === null && !hasDeltaListeners) || type.doc == null) return
  const change = type.toDelta({ renderer: type._renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true })
  if (!change.isEmpty()) {
    type._delta?.apply(change)
    if (hasDeltaListeners) type.emit('delta', [change, origin])
  }
}

/**
 * @param {any} a
 * @param {any} b
 * @return {boolean}
 */
export const equalFormats = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))

/**
 * @template {delta.DeltaConf} DConf
 * @typedef {delta.DeltaConfOverwrite<DConf, {
 *     attrs: { [K in keyof delta.DeltaConfGetAttrs<DConf>]: DeltaToYNode<delta.DeltaConfGetAttrs<DConf>[K]> },
 *     children: DeltaToYNode<delta.DeltaConfGetChildren<DConf>>
 *   }>
 * } DeltaConfDeltaToYNode
 */

/**
 * @template {any} Data
 * @typedef {Exclude<Data,delta.DeltaAny> | (Extract<Data,delta.DeltaAny> extends delta.Delta<infer DConf> ? (unknown extends DConf ? YNode<DConf> : never) : never)} DeltaToYNode
 */

/**
 * @param {YNode<any>} type
 * @param {number} start
 * @param {number} end
 * @return {Array<any>}
 *
 * @private
 * @function
 */
export const typeListSlice = (type, start, end) => {
  type.doc ?? warnPrematureAccess()
  if (start < 0) {
    start = type._length + start
  }
  if (end < 0) {
    end = type._length + end
  }
  let len = end - start
  const cs = []
  let n = type._start
  while (n !== null && len > 0) {
    if (n.countable && !n.deleted) {
      const c = n.content.getContent()
      if (c.length <= start) {
        start -= c.length
      } else {
        for (let i = start; i < c.length && len > 0; i++) {
          cs.push(c[i])
          len--
        }
        start = 0
      }
    }
    n = n.right
  }
  return cs
}

/**
 * @todo remove / inline this
 *
 * @param {YNode} type
 * @param {number} index
 * @return {any}
 *
 * @private
 * @function
 */
export const typeListGet = (type, index) => {
  type.doc ?? warnPrematureAccess()
  const marker = findMarker(type, index)
  let n = type._start
  if (marker !== null) {
    n = marker.p
    index -= marker.index
  }
  for (; n !== null; n = n.right) {
    if (!n.deleted && n.countable) {
      if (index < n.length) {
        return n.content.getContent()[index]
      }
      index -= n.length
    }
  }
}

/**
 * @todo this is a duplicate. use the unified insert function and remove this.
 *
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {Item?} referenceItem
 * @param {Array<YValue>} content
 *
 * @private
 * @function
 */
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
  let left = referenceItem
  const doc = transaction.doc
  const ownClientId = doc.clientID
  const store = doc.store
  const right = referenceItem === null ? parent._start : referenceItem.right
  /**
   * @type {Array<Object|Array<any>|number|null>}
   */
  let jsonContent = []
  const packJsonContent = () => {
    if (jsonContent.length > 0) {
      left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
      left.integrate(transaction, 0)
      jsonContent = []
    }
  }
  content.forEach(c => {
    if (c === null) {
      jsonContent.push(c)
    } else {
      switch (c.constructor) {
        case Number:
        case Object:
        case undefined:
        case Boolean:
        case Array:
        case String:
        case BigInt:
        case Date:
          jsonContent.push(c)
          break
        default:
          packJsonContent()
          switch (c.constructor) {
            case Uint8Array:
            case ArrayBuffer:
              left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
              left.integrate(transaction, 0)
              break
            default:
              if ($doc.check(c)) {
                left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, createContentDocFromDoc(/** @type {Doc} */ (c)))
                left.integrate(transaction, 0)
              } else if (c instanceof YNode) {
                left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(/** @type {any} */ (c)))
                left.integrate(transaction, 0)
              } else {
                throw new Error('Unexpected content type in insert operation')
              }
          }
      }
    }
  })
  packJsonContent()
}

const lengthExceeded = () => error.create('Length exceeded!')

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {number} index
 * @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
 *
 * @private
 * @function
 */
export const typeListInsertGenerics = (transaction, parent, index, content) => {
  if (index > parent._length) {
    throw lengthExceeded()
  }
  if (index === 0) {
    if (parent._searchMarker) {
      updateMarkerChanges(parent._searchMarker, index, content.length)
    }
    return typeListInsertGenericsAfter(transaction, parent, null, content)
  }
  const startIndex = index
  const marker = findMarker(parent, index)
  let n = parent._start
  if (marker !== null) {
    n = marker.p
    index -= marker.index
    // we need to iterate one to the left so that the algorithm works
    if (index === 0) {
      // @todo refactor this as it actually doesn't consider formats
      n = n.prev // important! get the left undeleted item so that we can actually decrease index
      index += (n && n.countable && !n.deleted) ? n.length : 0
    }
  }
  for (; n !== null; n = n.right) {
    if (!n.deleted && n.countable) {
      if (index <= n.length) {
        if (index < n.length) {
          // insert in-between
          getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
        }
        break
      }
      index -= n.length
    }
  }
  if (parent._searchMarker) {
    updateMarkerChanges(parent._searchMarker, startIndex, content.length)
  }
  return typeListInsertGenericsAfter(transaction, parent, n, content)
}

/**
 * Pushing content is special as we generally want to push after the last item. So we don't have to update
 * the search marker.
 *
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
 *
 * @private
 * @function
 */
export const typeListPushGenerics = (transaction, parent, content) => {
  // Use the marker with the highest index and iterate to the right.
  const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start })
  let n = marker.p
  if (n) {
    while (n.right) {
      n = n.right
    }
  }
  return typeListInsertGenericsAfter(transaction, parent, n, content)
}

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {number} index
 * @param {number} length
 *
 * @private
 * @function
 */
export const typeListDelete = (transaction, parent, index, length) => {
  if (length === 0) { return }
  const startIndex = index
  const startLength = length
  const marker = findMarker(parent, index)
  let n = parent._start
  if (marker !== null) {
    n = marker.p
    index -= marker.index
  }
  // compute the first item to be deleted
  for (; n !== null && index > 0; n = n.right) {
    if (!n.deleted && n.countable) {
      if (index < n.length) {
        getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
      }
      index -= n.length
    }
  }
  // delete all items until done
  while (length > 0 && n !== null) {
    if (!n.deleted) {
      if (length < n.length) {
        getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
      }
      n.delete(transaction)
      length -= n.length
    }
    n = n.right
  }
  if (length > 0) {
    throw lengthExceeded()
  }
  if (parent._searchMarker) {
    updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
  }
}

/**
 * @todo inline this code
 *
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {string} key
 *
 * @private
 * @function
 */
export const nodeMapDelete = (transaction, parent, key) => {
  const c = parent._map.get(key)
  if (c !== undefined) {
    c.delete(transaction)
  }
}

/**
 * @param {Transaction} transaction
 * @param {YNode} parent
 * @param {string} key
 * @param {YValue} value
 *
 * @private
 * @function
 */
export const nodeMapSet = (transaction, parent, key, value) => {
  const left = parent._map.get(key) || null
  const doc = transaction.doc
  const ownClientId = doc.clientID
  let content
  if (value == null) {
    content = new ContentAny([value])
  } else {
    switch (value.constructor) {
      case Number:
      case Object:
      case Boolean:
      case Array:
      case String:
      case Date:
      case BigInt:
        content = new ContentAny([value])
        break
      case Uint8Array:
        content = new ContentBinary(/** @type {Uint8Array} */ (value))
        break
      default:
        if ($doc.check(value)) {
          content = createContentDocFromDoc(/** @type {Doc} */ (value))
        } else if (value instanceof YNode) {
          content = new ContentType(/** @type {any} */ (value))
        } else {
          throw new Error('Unexpected content type')
        }
    }
  }
  new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
}

/**
 * @param {YNode<any>} parent
 * @param {string} key
 * @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined}
 *
 * @private
 * @function
 */
export const nodeMapGet = (parent, key) => {
  parent.doc ?? warnPrematureAccess()
  const val = parent._map.get(key)
  return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined
}

/**
 * @param {YNode<any>} parent
 * @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined>}
 *
 * @private
 * @function
 */
export const nodeMapGetAll = (parent) => {
  /**
   * @type {Object<string,any>}
   */
  const res = {}
  parent.doc ?? warnPrematureAccess()
  parent._map.forEach((value, key) => {
    if (!value.deleted) {
      res[key] = value.content.getContent()[value.length - 1]
    }
  })
  return res
}

/**
 * The item that stands for a map key in a change render. Normally the key's latest item — but a
 * deleted latest item may be churn: set *and* deleted by the change, so excluded from
 * `itemsToRender`, while the change deleted the value it overwrote and the consuming state still
 * holds that one. `insertedItems` identifies churn; step over it to the replaced value. The walk
 * only ever covers items the change itself inserted, never the key's history. A merged tombstone
 * spans several values, so membership is a range lookup. `null` if only churn is left.
 *
 * @param {Item} item the key's latest (deleted) item
 * @param {IdSet} itemsToRender
 * @param {IdSet?} insertedItems
 * @return {Item?}
 */
const mapKeyChangeItem = (item, itemsToRender, insertedItems) => {
  if (insertedItems === null) return item // churn can't be told apart from history
  let i = /** @type {Item?} */ (item)
  while (i !== null && !itemsToRender.intersects(i.id.client, i.id.clock, i.length) && insertedItems.hasId(i.id)) i = i.left
  return i
}

/**
 * @todo move this to getContent/getDelta
 *
 * Render the difference to another ydoc (which can be empty) and highlight the differences with
 * attributions.
 *
 * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the
 * attribution `{ isDeleted: true, .. }`.
 *
 * @template {delta.DeltaBuilderAny} TypeDelta
 * @param {TypeDelta} d
 * @param {YNode} parent
 * @param {Set<string|null>?} attrsToRender
 * @param {AbstractRenderer?} renderer
 * @param {boolean} deep
 * @param {Set<YNode>|Map<YNode,any>|null} [modified] - set of types that should be rendered as modified children
 * @param {IdSet?} [itemsToRender]
 * @param {any} [opts]
 * @param {any} [optsAll]
 *
 * @private
 * @function
 */
export const nodeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modified, itemsToRender, opts, optsAll) => {
  // @todo support modified ops!
  const insertedItems = /** @type {IdSet?} */ (opts?.insertedItems ?? null)
  /**
   * @param {Item} item
   * @param {string} key
   */
  const renderAttrs = (item, key) => {
    if (item.deleted && itemsToRender != null) {
      const i = mapKeyChangeItem(item, itemsToRender, insertedItems)
      if (i === null) return
      item = i
    }
    /**
     * @type {{ deleted: boolean, attrs: Array<ContentAttribute<any>>?, content: AbstractContent }}
     */
    let piece
    if (renderer === null || !renderer.hasItem(item)) {
      // generic fast path — the item's own fields, no renderer involved
      piece = { deleted: item.deleted, attrs: null, content: item.content }
    } else {
      /**
       * @type {Array<AttributedContent<any>>}
       */
      const cs = []
      renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1)
      if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete)
      piece = cs[cs.length - 1]
    }
    const { deleted, attrs, content } = piece
    const attribution = createAttributionFromAttributionItems(attrs, deleted)
    let c = array.last(content.getContent())
    if (deleted) {
      if (attribution != null) {
        // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode
        // or in an event-driven render). The attribute is still observable in the rendered state, so
        // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content
        // children are rendered for the same case (positive `InsertOp` with attribution, never
        // `DeleteOp`).
        // also re-emit when the change happened *inside* the still-rendered value (`modified`
        // contains the value type but the attr's own map item is not part of the change) — the
        // deleted value has no modifyAttr path, and a full-state `setAttr` replace is idempotent
        if (itemsToRender == null || itemsToRender.intersects(item.id.client, item.id.clock, item.length) || (c instanceof YNode && modified != null && modified.has(c))) {
          if (deep && c instanceof YNode) {
            // full-state value render: a positive `setAttr` *replaces* the attr value on the
            // consuming side, so the nested type must render as its full attributed state
            // (change-scoped opts like `itemsToRender` would render bare retains here)
            c = /** @type {any} */(c).toDelta({ renderer, deep: true })
          }
          d.setAttr(key, c, attribution)
        }
      } else if (itemsToRender != null && itemsToRender.intersects(item.id.client, item.id.clock, item.length)) {
        // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the
        // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In
        // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders
        // run with `render === false` for such items, so nothing was emitted before either).
        d.deleteAttr(key, attribution)
      }
    } else if (deep && c instanceof YNode && modified?.has(c)) {
      // the attr item itself may be part of the change too (e.g. its insert-suggestion was accepted
      // along with its content): carry its attribution as the `modifyAttr` tri-state — merge when
      // present, clear (`null`) when gone, skip (`undefined`) when the item is not in the change
      d.modifyAttr(key, c.toDelta(opts), itemsToRender != null && itemsToRender.hasId(item.lastId) ? (attribution ?? null) : undefined)
    } else {
      if (deep && c instanceof YNode) {
        // full-state value render, as in the deleted case above: `setAttr` replaces the value on the
        // consuming side, and the change-scoped opts only happen to yield the full state when every
        // item inside the node is in `itemsToRender` (a partial accept / a hand-built id set is not)
        c = /** @type {any} */(c).toDelta({ renderer, deep: true })
      }
      d.setAttr(key, c, attribution)
    }
  }
  if (attrsToRender == null) {
    parent._map.forEach(renderAttrs)
  } else {
    attrsToRender.forEach(key => key != null && renderAttrs(/** @type {Item} */ (parent._map.get(key)), key))
  }
}

/**
 * @param {YNode<any>} parent
 * @param {string} key
 * @return {boolean}
 *
 * @private
 * @function
 */
export const nodeMapHas = (parent, key) => {
  parent.doc ?? warnPrematureAccess()
  const val = parent._map.get(key)
  return val !== undefined && !val.deleted
}

/**
 * @param {Item} item
 * @param {Snapshot|undefined} snapshot
 *
 * @protected
 * @function
 */
export const isVisible = (item, snapshot) => snapshot === undefined
  ? !item.deleted
  : snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !snapshot.ds.hasId(item.id)

/**
 * @param {YNode<any>} parent
 * @param {string} key
 * @param {Snapshot} snapshot
 * @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined}
 *
 * @private
 * @function
 */
export const nodeMapGetSnapshot = (parent, key, snapshot) => {
  let v = parent._map.get(key) || null
  while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
    v = v.left
  }
  return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined
}

/**
 * @param {YNode<any>} parent
 * @param {Snapshot} snapshot
 * @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined>}
 *
 * @private
 * @function
 */
export const nodeMapGetAllSnapshot = (parent, snapshot) => {
  /**
   * @type {Object<string,any>}
   */
  const res = {}
  parent._map.forEach((value, key) => {
    /**
     * @type {Item|null}
     */
    let v = value
    while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
      v = v.left
    }
    if (v !== null && isVisible(v, snapshot)) {
      res[key] = v.content.getContent()[v.length - 1]
    }
  })
  return res
}

/**
 * @param {YNode<any> & { _map: Map<string, Item> }} type
 * @return {IterableIterator<Array<any>>}
 *
 * @private
 * @function
 */
export const createMapIterator = type => {
  type.doc ?? warnPrematureAccess()
  return iterator.iteratorFilter(type._map.entries(), /** @param {any} entry */ entry => !entry[1].deleted)
}

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentType}
 */
export const readContentType = decoder => new ContentType(readYNode(decoder))

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentString}
 */
export const readContentString = decoder => new ContentString(decoder.readString())

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentJSON}
 */
export const readContentJSON = decoder => {
  const len = decoder.readLen()
  const cs = []
  for (let i = 0; i < len; i++) {
    const c = decoder.readString()
    if (c === 'undefined') {
      cs.push(undefined)
    } else {
      cs.push(JSON.parse(c))
    }
  }
  return new ContentJSON(cs)
}

/**
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentFormat}
 */
export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON())

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentEmbed}
 */
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentDoc}
 */
export const readContentDoc = decoder => new ContentDoc(decoder.readString(), decoder.readAny())

/**
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {ContentAny}
 */
export const readContentAny = decoder => {
  const len = decoder.readLen()
  const cs = []
  for (let i = 0; i < len; i++) {
    cs.push(decoder.readAny())
  }
  return new ContentAny(cs)
}

/**
 * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
 * @return {ContentBinary}
 */
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())

/**
 * @private
 *
 * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
 * @return {ContentDeleted}
 */
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())

/**
 * A lookup map for reading Item content.
 *
 * @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
 */
export const contentRefs = [
  () => { error.unexpectedCase() }, // GC is not ItemContent
  readContentDeleted, // 1
  readContentJSON, // 2
  readContentBinary, // 3
  readContentString, // 4
  readContentEmbed, // 5
  readContentFormat, // 6
  readContentType, // 7
  readContentAny, // 8
  readContentDoc, // 9
  () => { error.unexpectedCase() } // 10 - Skip is not ItemContent
]

/**
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @param {number} info
 */
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)

/**
 * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
 * @return {YNode}
 *
 * @private
 * @function
 */
export const readYNode = decoder => {
  const typeRef = decoder.readTypeRef()
  const ynode = new YNode(typeRef === YXmlElementRefID || typeRef === YXmlHookRefID ? decoder.readKey() : null)
  ynode._legacyTypeRef = typeRef
  return ynode
}