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
- Check browser console / network for the portal script failing to load; confirm {{ghost_head}} is in the theme.
- Increase attempts or the per-attempt timeout for slow environments.
- Verify the portalIframe selector still matches the current Portal DOM.
- 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
- Ensure the theme includes {{ghost_head}} so the portal script loads.
- Wait for page load (waitForLoadState) before clicking the trigger.
- Keep the iframe selector in sync with Portal DOM changes.
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
- Error going to signin page (no response)
- Error going to signin page (${response.status})
- [e2e fixture] resetEnvironment() must be called before resol
- Failed to update newsletter
- Failed to update member
AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13).
Data as JSON: /api/errors/cf2f66a7682b3260.
Report an issue: GitHub.