/** * ONE `ResizeObserver` and ONE `MutationObserver` for every scrolling box in the * document, rather than a pair per mount. Handlers register per target; the * shared observers observe an element the first time anyone asks and drop it once * nobody wants it. */ /** * The handlers waiting on each observed element. A SET, not a single handler: * one element can be both a box and another box's child — a `
` inside a * `ScrollRegion` is exactly that — and both owners must still hear its resize. */ type ResizeHandler = () => void; const resizeHandlers = new WeakMap>(); let sharedResize: ResizeObserver | undefined; function sharedResizeObserver(): ResizeObserver { sharedResize ??= new ResizeObserver((entries) => { // A box and its children are observed separately, so one frame can deliver // several entries owned by the same handler; measuring once is enough. const fired = new Set (); for (const entry of entries) { for (const handler of resizeHandlers.get(entry.target) ?? []) fired.add(handler); } for (const handler of fired) handler(); }); return sharedResize; } /** * Registers `handler` for `target`'s resizes, observing the element only the * first time anyone asks for it: re-observing an element already under a * `ResizeObserver` re-arms its initial notification, which spends a forced * layout on a measurement nobody asked for. */ export function holdResizeTarget(target: Element, handler: ResizeHandler): void { const handlers = resizeHandlers.get(target); if (handlers !== undefined) { handlers.add(handler); return; } // Registered before the element is observed, so the initial notification the // observation arms reaches the handler that asked for it. resizeHandlers.set(target, new Set ([handler])); sharedResizeObserver().observe(target); } /** Stops observing `target` for `handler`, and entirely once nobody wants it. */ export function releaseResizeTarget(target: Element, handler: ResizeHandler): void { const handlers = resizeHandlers.get(target); if (handlers === undefined) return; handlers.delete(handler); if (handlers.size > 0) return; resizeHandlers.delete(target); sharedResizeObserver().unobserve(target); } /** What a box wants to hear about: its content replaced, appended or edited. */ const CONTENT_OBSERVER_INIT: MutationObserverInit = { childList: true, subtree: true, characterData: true, }; type ContentHandler = () => void; /** * The handlers waiting on each observed box, and the boxes the shared observer * currently holds. Keyed by `Node` because a `characterData` record names the * TEXT node that changed, and the walk to the box that owns it goes through * whatever nodes lie between. */ const contentHandlers = new WeakMap >(); const contentTargets = new Set (); let sharedContent: MutationObserver | undefined; /** Registrations the shared observer still holds for released boxes. */ let staleContentTargets = 0; function deliverContentRecords(records: readonly MutationRecord[]): void { const fired = new Set (); for (const record of records) { // A `childList` record names the changed parent and a `characterData` record // the text node itself, either of them at any depth under a box. Every box // on the ancestor chain therefore owns the change, exactly as a separate // observer on each of them would have seen it — a ` ` inside a // `ScrollRegion` measures both. for (let node: Node | null = record.target; node !== null; node = node.parentNode) { for (const handler of contentHandlers.get(node) ?? []) fired.add(handler); } } for (const handler of fired) handler(); } function sharedContentObserver(): MutationObserver { sharedContent ??= new MutationObserver(deliverContentRecords); return sharedContent; } export function holdContentTarget(target: Element, handler: ContentHandler): void { let handlers = contentHandlers.get(target); if (handlers === undefined) { handlers = new Set(); contentHandlers.set(target, handlers); contentTargets.add(target); sharedContentObserver().observe(target, CONTENT_OBSERVER_INIT); } handlers.add(handler); } /** * Stops watching `target`'s content for `handler`. * * `MutationObserver` has no `unobserve`: the only way to drop one registration * is to disconnect the observer and re-observe everything else, which would cost * one registration per surviving box every time a box unmounts. A released box * is therefore dropped from the handler map at once — its records find no * handler and are ignored — and the observer is rebuilt only once the dead * registrations outnumber the live ones, which keeps the total registration work * proportional to the number of mounts. Records already queued survive the * rebuild: they are taken off the observer and delivered by hand. */ export function releaseContentTarget(target: Element, handler: ContentHandler): void { const handlers = contentHandlers.get(target); if (handlers === undefined) return; handlers.delete(handler); if (handlers.size > 0) return; contentHandlers.delete(target); contentTargets.delete(target); staleContentTargets += 1; if (staleContentTargets <= contentTargets.size) return; const observer = sharedContentObserver(); const queued = observer.takeRecords(); observer.disconnect(); staleContentTargets = 0; for (const live of contentTargets) observer.observe(live, CONTENT_OBSERVER_INIT); if (queued.length > 0) deliverContentRecords(queued); }