TryGhost/Ghost · error · Error

Portal popup did not open

Error message

Portal popup did not open

What it means

openPortalIfNotOpen() clicks the trigger link up to 5 times, each time waiting up to 1s for the portal iframe to become visible. If all 5 attempts fail it throws. The retry exists specifically because Portal's hashchange listener initializes asynchronously and may drop the first clicks. The throw means Portal never became interactive within ~5s.

Source

Thrown at e2e/helpers/pages/public/public-page.ts:48

     * Playwright's retry mechanism to handle the race condition deterministically.
     */
    async clickLinkAndWaitForPopup(link: Locator): Promise<void> {
        await this.portalScript.waitFor({state: 'attached'});
        await this.portalRoot.waitFor({state: 'attached'});

        // Retry until Portal finishes async init and can react to the click.
        for (let attempt = 0; attempt < 5; attempt++) {
            await link.click();

            try {
                await this.portalIframe.waitFor({state: 'visible', timeout: 1000});
                return;
            } catch {
                // Keep retrying until Portal's hashchange listener is ready.
            }
        }

        throw new Error('Portal popup did not open');
    }

    async isPortalOpen(): Promise<boolean> {
        const count = await this.portalFrame.count();
        if (count === 0) {
            return false;
        }
        return await this.portalFrame.isVisible();
    }
}

export class PublicPage extends BasePage {
    public readonly portalRoot: Locator;
    public readonly portalIframe: Locator;
    public readonly portalPopupFrame: Locator;
    public readonly portalScript: Locator;
    private readonly subscribeLink: Locator;
    private readonly signInLink: Locator;

View on GitHub (pinned to 47d8b0e2ad)

Solutions

  1. Check browser console / network for the portal script failing to load; confirm {{ghost_head}} is in the theme.
  2. Increase attempts or the per-attempt timeout for slow environments.
  3. Verify the portalIframe selector still matches the current Portal DOM.
  4. Ensure the public page fully loaded before clicking (waitForLoadState).

Example fix

// before
for (let attempt = 0; attempt < 5; attempt++) {
    await link.click();
    try { await this.portalIframe.waitFor({state:'visible', timeout: 1000}); return; } catch {}
}
throw new Error('Portal popup did not open');

// after
for (let attempt = 0; attempt < 10; attempt++) {
    await link.click();
    try { await this.portalIframe.waitFor({state:'visible', timeout: 2000}); return; } catch {}
}
throw new Error('Portal popup did not open after 10 attempts');
Defensive patterns

Strategy: retry

Validate before calling

// Confirm the portal script is injected before clicking:
const hasPortal = await page.evaluate(() => !!document.querySelector('script[src*="portal"]'));
if (!hasPortal) throw new Error('Portal script not present on page');

Try / catch

for (let attempt = 0; attempt < 10; attempt++) {
    await link.click();
    try { await this.portalIframe.waitFor({state:'visible', timeout: 2000}); return; }
    catch { /* Portal init is async; retry */ }
}
throw new Error('Portal popup did not open');

Prevention

When it happens

Trigger: Portal JS bundle failed to load (CDN/script error) so the iframe never renders. The trigger link selector changed. Portal opened in a mode/location the iframe selector doesn't match. Heavy page where Portal init took >5s.

Common situations: Public app bundle (portal) missing or misbuilt; theme missing {{ghost_head}} so the portal script isn't injected; iframe selector drift after a Portal UI change; slow CI machine.

Related errors


AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13). Data as JSON: /api/errors/cf2f66a7682b3260. Report an issue: GitHub.