Your App Bridge <Modal variant="max"> opens to a spinner that never resolves
If you build embedded Shopify admin apps and you've shipped a <Modal variant="max"> that usually works, this is for you. If your modal has never flaked, you don't need this — go read something else.
Before you start
- An embedded Shopify admin app with App Bridge React working. → Shopify: build an admin app
The learning
<Modal variant="max"> has a race condition you cannot fix from your side.
When shopify.modal.show() fires, Shopify admin creates an iframe for your modal content and postMessages into it. Sometimes it sends that message before the iframe has navigated to your origin. The origins don't match, the browser drops the message on the floor, and your modal chrome renders around a spinner that spins forever.
It is not your code. It's a race between two things you don't control: admin's postMessage and the iframe's navigation.
It failed for me ~20% of the time, concentrated after dev-server restarts. Which is exactly the frequency that ruins you: rare enough that you blame yourself, common enough to hit a customer.
The fix is <s-app-window>. It manages its own iframe lifecycle, so the race doesn't exist.
The part that cost me the most: retries don't work
The instinct is to retry. modal.show() didn't take, so call it again. I shipped this:
const maxRetries = 3;
const retryDelayMs = 1500;
tryShow();
const retryInterval = setInterval(() => {
retryCount++;
if (retryCount >= maxRetries) {
clearInterval(retryInterval);
shopify.toast.show("Modal failed to open — please refresh the page", { isError: true });
return;
}
tryShow();
}, retryDelayMs);Read that toast again. I shipped a UI that asks the user to refresh the page. That's where this approach ends up.
I didn't trust my own impression of whether it helped, so I wrote a counter into localStorage that recorded, for every open, which attempt succeeded:
interface ModalCycleEvent {
attemptsNeeded: number; // 1,2,3 = succeeded on that attempt; 0 = all failed
succeeded: boolean;
}retrySuccesses stayed at zero. Not low — zero. Every success was attempt 1; every failure was all three.
That's the actual learning, and it's the one thing no amount of reading the docs gives you: if the first modal.show() fails, every subsequent one fails too. The race is decided before your first call returns. Retrying is a slower way to lose.
I also tried mounting the <Modal> behind a 3-second timer, on the theory that the iframe needed time to settle. Same outcome, worse UX.
Minimum steps
1. Reproduce it first. Put both approaches on one throwaway debug route and click them after a dev-server restart:
// app/routes/app.debug.proto-modal.tsx
export default function ProtoModalPage() {
const shopify = useAppBridge();
return (
<Page title="Proto: Modal vs App Window">
<Button onClick={() => shopify.modal.show("proto-modal")}>Open Modal</Button>
<Button onClick={() => (document.getElementById("proto-window") as any).show()}>
Open App Window
</Button>
<Modal id="proto-modal" variant="max">
<Box padding="800">
<Text as="h1" variant="headingXl">If you see this, the modal opened.</Text>
</Box>
</Modal>
<s-app-window id="proto-window" src="/app/debug/proto-window-content?id=abc123"></s-app-window>
</Page>
);
}Observable end state: restart the dev server, hard-refresh admin, click Open Modal repeatedly. Roughly one in five opens gives you chrome with a spinner and no content, and a postMessage origin error in the console. Open App Window never does.
2. Swap the element. The parent holds the window and points it at a route:
<s-app-window id="editor-window"></s-app-window>const el = document.getElementById("editor-window") as any;
el.setAttribute("src", `/app/items/${itemId}`);
el.show();3. Move your content into a real route. s-app-window loads a URL, so what was modal content becomes a route with its own loader. Pass ids as URL params.
4. Detect close with the native hide event:
el.addEventListener("hide", () => { /* cleanup */ });5. Talk to the parent over BroadcastChannel, not postMessage:
const channel = new BroadcastChannel("editor");
channel.onmessage = (event) => { /* parent reacts */ };6. Delete the retry code. All of it.
Gotchas nobody warns you about
Everything below cost me time after the migration looked done.
<Modal>crashes insides-app-window. Every confirm/reject/delete dialog nested in your old modal has to become an inlineposition: fixedoverlay. This is the single biggest cost of the move — budget for it before you start, not after.sessionStorageis iframe-isolated.localStorageis not. Anything you stashed insessionStorageand expected to read from the parent is simply gone.- Polaris CSS does not come with you. The child route needs its own
links()export withpolarisStyles, or every component renders unstyled and you will briefly think you broke the app. useRevalidator()in the child revalidates the child. Your parent list will not refresh. Send aBroadcastChannelmessage and have the parent call its own revalidator.- Your store is a new instance. Separate iframe, separate React tree, separate module singleton. Usually fine — each session starts fresh from the loader — but it will surprise you once.
onHidecallbacks get cached. Read state from the store directly (useStore.getState()), not from a React closure, or you'll act on stale values.- Escape may close the window out from under you. If a nested overlay needs Escape,
stopPropagation().
Official docs
- App Bridge
Modal— https://shopify.dev/docs/api/app-bridge-library/react-components/modal s-app-window— https://shopify.dev/docs/api/app-home/app-bridge-web-components/app-windowBroadcastChannelfor modal/window communication — https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
The docs cover the happy path for both elements and cover it well. What they don't say is that one of them has a race, or which way to jump. That's this post.
What I'd tell you in one line
If you're reaching for <Modal variant="max"> today, don't. Start on <s-app-window>. If you already shipped the modal and it flakes, the migration is a day's work and the retries you're about to write have already been proven not to help.
Or let your AI do the migration
Paste this into Claude Code, Cursor, or whatever you use. It checks the prerequisite first, then does the swap. It will not touch your editor's actual contents.
The constraints are the point. Ask any AI to "fix my flaky max modal" and it will write the retry loop — the same one that was measured at a zero percent success rate above — because it's reading the same docs that don't mention the race.
Migrate an embedded Shopify admin app from App Bridge `<Modal variant="max">` to the
`<s-app-window>` web component, because `variant="max"` has a postMessage/navigation race
that hangs on a spinner and cannot be fixed from app code.
Stack: Remix, TypeScript, React, Polaris, App Bridge React. Shopify API version 2026-01.
First, check that App Bridge React is installed and `useAppBridge()` works on at least one
route. If it isn't, set that up before anything else and tell me what you added.
Then do exactly this and nothing more:
1. Find every `<Modal variant="max">` in the app and list them for me before changing
anything. Ask which one to migrate if there is more than one.
2. On the parent route, replace the modal with a `<s-app-window id="editor-window">`
element that stays mounted. It's a web component, so add
`{/* @ts-expect-error s-app-window is a Shopify web component */}` above it.
3. Replace the `shopify.modal.show(...)` call with: set the element's `src` attribute to
the child route URL, then call `.show()` on the element. Two lines.
4. Move the old modal's content into a real Remix route with its own loader that calls
`authenticate.admin(request)`. Pass ids as URL params, not props. Stub any data
fetching — do not invent my data model.
5. Replace close detection with the element's native `hide` event on the parent.
6. Wire `BroadcastChannel("editor")`: the child posts an update message, the parent
listens and calls its own `useRevalidator().revalidate()`. Close the channel on unmount.
7. Delete all retry logic around modal opening — intervals, retry counters, timeouts,
"please refresh the page" toasts, and any localStorage instrumentation of attempts.
Constraints — these are the traps; obey them exactly:
- Do NOT add retry logic, a setTimeout before showing, or a readiness poll. Retrying a
failed open has a measured zero percent success rate; the race is decided before the
first call returns.
- The child route MUST export its own `links()` including `polarisStyles`, or wrap its
content in `<AppProvider>`. It does NOT inherit the parent's Polaris CSS.
- Do NOT render App Bridge `<Modal>` inside the child — it crashes there. Convert every
nested confirm/delete/reject dialog to a `position: fixed` overlay.
- Do NOT call `useRevalidator()` in the child expecting the parent to refresh. It
revalidates the child only.
- Do NOT use `sessionStorage` to pass anything between parent and child; it is
iframe-isolated. `localStorage` is shared.
- Do NOT assume parent and child share a store instance. Separate iframes, separate
React trees.
- In any hide/close handler, read state via `useStore.getState()`, not from a React
closure — closures there can be stale.
- If a nested overlay needs the Escape key, `stopPropagation()` so Escape doesn't close
the whole window.
Acceptance check: restart the dev server, hard-refresh the admin, and open the surface
ten times in a row. It renders content every time — no spinner-only state, no
postMessage origin error in the console. Saving in the child and closing it updates the
parent with no page refresh. Tell me if either fails.Verified against App Bridge React (Modal, s-app-window), Chrome 144.