When injecting a content script into a SPA or complex web app, you cannot know which elements are present in the DOM right after startup. Trying to set up narrow observers immediately will fail because the target elements do not yet exist.
The layered DOM observation strategy solves this problem in two phases:
- Waiting phase: Observe the app root broadly and wait for a “readiness signal” element to appear
- Active phase: Once the signal is detected, swap to scope-limited observers for each region. If a URL change is detected, revert to the waiting phase
Code
type ObserverLayer = { selector: string callback: MutationCallback options?: MutationObserverInit}
const activeObservers = new Map<string, MutationObserver>()
const detachAll = (): void => { for (const obs of activeObservers.values()) { obs.disconnect() } activeObservers.clear()}
const attachLayer = (layer: ObserverLayer): void => { const el = document.querySelector(layer.selector) if (!el) return activeObservers.get(layer.selector)?.disconnect() const obs = new MutationObserver(layer.callback) obs.observe(el, layer.options ?? { childList: true, subtree: true }) activeObservers.set(layer.selector, obs)}
export const createLayeredObserver = ( appRootSelector: string, readySelector: string, layers: ObserverLayer[],): (() => void) => { let lastUrl = location.href let phase: 'waiting' | 'active' = 'waiting'
const rootObserver = new MutationObserver(() => { const currentUrl = location.href
if (currentUrl !== lastUrl) { lastUrl = currentUrl phase = 'waiting' detachAll() }
if (phase === 'waiting' && document.querySelector(readySelector)) { phase = 'active' detachAll() for (const layer of layers) { attachLayer(layer) } } })
const root = document.querySelector(appRootSelector) ?? document.body rootObserver.observe(root, { childList: true, subtree: true })
return () => { rootObserver.disconnect() detachAll() }}Usage
const teardown = createLayeredObserver( '#app', // app root (broad watch) '[data-ready]', // readiness signal element [ { selector: '.stream', callback: (mutations) => { for (const m of mutations) console.log('stream:', m.type) }, }, { selector: '.sidebar', callback: (mutations) => { for (const m of mutations) console.log('sidebar:', m.type) }, }, { selector: '[role="dialog"]', callback: (mutations) => { for (const m of mutations) console.log('modal:', m.type) }, }, ],)
// Stop all observersteardown()How It Works
rootObserverwatches the app root broadly with{ childList: true, subtree: true }- On every callback invocation,
location.hrefis compared to the previous value; if the URL changed, all layer observers are torn down andphaseis reset to'waiting' - When
phase === 'waiting'andreadySelectoris found in the DOM,phasebecomes'active'and narrower observers are attached for each layer - The root observer stays running throughout, so SPA navigation is handled automatically
Applications
This pattern has no dependency on a specific framework or selector set. It applies to:
- SPA content scripts: Re-initialize processing in Chrome Extensions or Userscripts on route changes
- Third-party DOM integrations: Attach observers to a page that embeds an external service
- Analytics overlays: Listen only to specific regions and fire events on changes
- Auto-enhancers: Track element appearance and removal in complex injected web apps
hsb.horse