SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ application-customizer-runs-in-dialog-iframe.md
sharepoint-vault›gotchas›spfx›application-customizer-runs-in-dialog-iframe.mdhistoryedit on GitHub
● GOTCHAspfx· 6 min read

Your Application Customizer runs again inside SharePoint dialog iframes

applies to: SharePoint Online (SPFx Application Customizer / any extension rendering floating UI)reviewed: 2026-07-28#spfx#application-customizer#extensions#dialogs#iframe#ui#mutationobserver

SharePoint hosts some of its dialogs as a page of the same site inside an iframe (IsDlg=1). Your Application Customizer is registered on that site, so it loads a second time in the iframe and paints your floating button again — pinned to the dialog's corner instead of the window's. Refuse to render when window.self !== window.top (or IsDlg=1 is in the query string) before you do anything else.

SharePoint hostí část svých dialogů jako stránku téhož webu v iframu (IsDlg=1). Application Customizer je na webu registrovaný, takže se v iframu načte podruhé a vykreslí plovoucí tlačítko znovu – přilepené do rohu dialogu, ne okna. Hned na začátku odmítni vykreslení, když window.self !== window.top (nebo je v query stringu IsDlg=1).

Symptom

Your extension renders a floating action button (chat bubble, help launcher, feedback tab) in the bottom-right corner of every page. It works.

Then a user opens a SharePoint dialog — Site contents → New → Document library is the usual one — and there are suddenly two buttons: the real one in the window corner, and a duplicate inside the dialog, sitting on top of its Cancel button.

Everything else looks normal. No console errors. A hard refresh brings back a single button until the dialog is opened again.

Cause

Two things combine.

1. The dialog is an iframe of your own site. Not every SharePoint dialog is an in-page React layer; several still load a _layouts page with IsDlg=1 inside an iframe. That page belongs to the same site, so every extension deployed to the site — yours included — boots inside it as a separate document with its own DOM.

2. position: fixed resolves against the iframe's viewport. That is why the duplicate is not stacked on the original: it is offset by your exact right/bottom values from the dialog's edges, because the dialog is the viewport of that document.

That geometry is also the fastest way to diagnose this from a screenshot alone. A position: fixed element attached to document.body cannot be positioned relative to some other element on the page — unless it lives in a nested browsing context. (A CSS transform/filter/backdrop-filter on an ancestor produces the same offset effect, but only for elements inside that ancestor — see Fixed dropdowns in transformed panels. For a body-level node, an iframe is the only explanation.)

The usual guard does not help:

// Only protects against a double mount in the SAME document.
if (document.getElementById(CONTAINER_ID)) return;

Each document has its own DOM, so the iframe copy sees no container and renders happily.

Fix

Refuse nested browsing contexts first — before the config reads, the host checks, everything:

private async _render(): Promise<void> {
  // 1) Never render in an iframe. SharePoint hosts some dialogs as a page of the
  //    same site inside an iframe, where our extension loads a second time.
  try {
    if (window.self !== window.top) return;
  } catch {
    return; // cross-origin top — still nested, still refuse
  }
  // 2) Belt and braces: the dialog-mode query flag.
  if ((window.location.search || '').toLowerCase().indexOf('isdlg=1') !== -1) return;

  // …host checks, configuration read, render…
}

Notes:

  • Keep the catch. Reading window.top across origins throws; a thrown check must mean "refuse", not "continue".
  • Both checks earn their place. The iframe test catches every nesting (dialogs, page embeds, previews); IsDlg=1 also covers a dialog page opened in its own tab, where the chrome is stripped but your floating UI would still be out of place.
  • This is not only about dialogs. Anything that embeds a page of your site into another page produces the same duplicate.

Related: a guard placed before an await is not a guard

The same feature usually carries a second, quieter version of the bug:

if (document.getElementById(CONTAINER_ID)) return;   // sync check
const cfg = await readConfiguration();               // ← the gap
document.body.appendChild(container);                // claim happens here

Two concurrent calls (a slow first read racing a re-validation on visibilitychange, say) both pass the check and both mount — this time perfectly overlapping, so nobody notices except that clicks hit the top copy and one React tree leaks. onDispose cleans up by id and removes only one.

Hold a synchronous lock across the whole async path, and release it in finally so a run that decided not to render still allows a later retry:

private _rendering = false;

private async _tryRender(): Promise<void> {
  if (this._rendering) return;
  if (document.getElementById(CONTAINER_ID)) return;
  this._rendering = true;
  try { await this._renderOnce(); } finally { this._rendering = false; }
}

How to verify

  1. Open a page of the site where the extension is deployed. One floating button.
  2. Go to Site contents → New → Document library (any dialog that shows its own scrollbar is a good candidate). Still one button.
  3. In DevTools, check the frame dropdown in the console: switch to the dialog's frame and run document.querySelectorAll('[id="<your-container-id>"]').length — expect 0 there and 1 in top.

Related trap (2026-07-28): a floating element needs an un-render path, and a "definitive no"

Two more ways the same tenant-wide customizer misbehaves once you add resilience to it. Both were found by auditing our own fix, so they are worth stating plainly.

1. Only rendering is not enough — hide conditions must be re-evaluated

Modern SharePoint is a SPA: onInit runs once, navigatedEvent fires on every in-page navigation. Teams usually add a re-render there (the element otherwise disappears when SharePoint prunes unknown body children). But if you only ever render, the reverse never happens: once the button exists, conditions like "this page hosts the full app", "the feature was switched off", or "this is the app's own page" are never checked again, so the button stays parked on top of the app forever.

Give the class an _unrender() (unmount + removeChild + clear timers) and call it from every hide branch, plus pass a force flag on navigation so the render path re-evaluates even when the container already exists:

private async tryRender(force?: boolean): Promise<void> {
  if (this.rendering) return;                                  // sync lock across the whole async path
  if (!force && document.getElementById(ID)) return;           // nothing to do
  this.rendering = true;
  try {
    const outcome = await this.renderOnce();                   // 'shown' | 'hidden-final' | 'hidden-retry'
    …
  } finally { this.rendering = false; }
}

2. A MutationObserver on body needs a definitive negative, or it hammers the tenant

The usual watchdog is new MutationObserver(...).observe(document.body, {childList: true}) — cheap, because it only sees direct children. Except SharePoint adds and removes direct body children constantly (callouts, tooltips, layers), so the callback fires all day. If the callback re-runs a configuration read whenever the container is missing, then on every site where your extension is deployed tenant-wide but the app is not configured (list 404) or the feature is off, you generate a request every throttle window, per user, for the whole session.

Split the outcome three ways:

  • shown → keep the observer connected.
  • hidden-final (feature off, no config row, app's own page, iframe, wrong host) → set a skipUntilNavigation flag and disconnect() the observer. Re-arm it on navigatedEvent.
  • hidden-retry (HTTP 429/5xx — the answer may change in a second) → keep the observer, do not latch the flag. Treating a transient error as definitive is the mirror image of the catch → default trap: the user loses the feature for the rest of the page with no trace.

And do not latch on the cheap synchronous check either: navigatedEvent can fire before SharePoint unmounts the previous page's web part, so a "the full app is on this page" marker may still be in the DOM while you are on your way out of that page.

Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-07-28last updated: 2026-09-24historyedit on GitHubview source