Skip to content

Centralize shape culling display updates to reduce per-shape subscriptions #7831

@steveruizok

Description

@steveruizok

Problem

Each Shape component in packages/editor/src/lib/components/Shape.tsx has its own useQuickReactor that subscribes to editor.getCulledShapes() to update its display property:

useQuickReactor(
    'set display',
    () => {
        const shape = editor.getShape(id)
        if (!shape) return

        const culledShapes = editor.getCulledShapes()
        const isCulled = culledShapes.has(id)
        if (isCulled !== memoizedStuffRef.current.isCulled) {
            setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block')
            setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block')
            memoizedStuffRef.current.isCulled = isCulled
        }
    },
    [editor]
)

With N shapes on the canvas, this creates N separate subscriptions all watching the same computed value. When culled shapes change, all N reactors run even though most shapes' culling state hasn't changed.

Proposed solution

Create a centralized registry of shape containers that allows a single reactor to imperatively update display properties:

  1. Registry: A Map<TLShapeId, { container, bgContainer }> that tracks shape container refs
  2. Registration: Shape components register their refs on mount, unregister on unmount
  3. Single reactor: One centralized subscription that diffs the culled shapes set and only updates containers whose state changed

This changes the complexity from O(N) subscriptions to O(1) subscription, with O(changed shapes) DOM updates.

Implementation sketch

// Registry (could live on Editor or as a separate manager)
class ShapeContainerRegistry {
  private containers = new Map<TLShapeId, {
    container: HTMLDivElement | null
    bgContainer: HTMLDivElement | null
  }>()

  register(id, container, bgContainer) { ... }
  unregister(id) { ... }
  
  updateCulling(culledShapes: Set<TLShapeId>, prevCulled: Set<TLShapeId>) {
    // Only update shapes whose culling state changed
    for (const id of culledShapes) {
      if (!prevCulled.has(id)) {
        // Newly culled - hide
      }
    }
    for (const id of prevCulled) {
      if (!culledShapes.has(id)) {
        // Newly visible - show
      }
    }
  }
}

// Single reactor in DefaultCanvas/ShapesToDisplay
function CullingController() {
  const editor = useEditor()
  const prevCulledRef = useRef<Set<TLShapeId>>(new Set())

  useQuickReactor('update culling', () => {
    const culledShapes = editor.getCulledShapes()
    editor.shapeContainerRegistry.updateCulling(culledShapes, prevCulledRef.current)
    prevCulledRef.current = culledShapes
  }, [editor])

  return null
}

Prior art

There's already a similar centralized pattern in DefaultCanvas.tsx with ReflowIfNeeded (lines 410-429) that has a single reactor watching getCulledShapes() for a different purpose.

Considerations

  • Registry could live on the Editor class or be provided via React context
  • Need to ensure registration happens before the centralized reactor first runs
  • Initial culling state needs to be set correctly on mount

Metadata

Metadata

Assignees

Labels

performanceImprove performance of an existing featuresdkAffects the tldraw sdk

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions