Andrew, the pin was worth doing, but I had the 202 wrong and I should correct that before you chase it further.
202 is not an error here. In react-router 7.18.2 the constant is literally SINGLE_FETCH_REDIRECT_STATUS = 202. When a loader throws a redirect on a .data request, the server deletes the Location header, sets content-type to text/x-script, puts the target URL inside the turbo-stream body, and returns 202. So /_root.data 202 only tells you your root loader threw a redirect. It would have done the same on v8. Pinning was never going to move that number.
What breaks the frame is where the redirect points. Client side, react-router looks at the location, and if it is an absolute URL on a different origin it calls window.location.assign instead of routing. That is a real navigation of your iframe, and the shopify.com surface it lands on refuses framing. That is your box.
So read the body of the 202, not the status. DevTools, Network tab, click _root.data, Response. The URL is sitting in there as plain text.
Now the part that points at a cause. In @shopify/shopify-app-react-router 2.0.0, a data request that carries the session token never gets a plain redirect. helpers/respond-to-invalid-session-token.js computes isDocumentRequest = !request.headers.get('authorization'), and when the header is present the failure comes back as 401 with X-Shopify-API-Request-Failure-Reauthorize-Url, which App Bridge catches and handles inside the frame. You are getting 202, not 401. That means the _root.data request went out with no Authorization header, so authenticate.admin treated it as a document request. admin/authenticate.js gates that whole branch on !getSessionTokenHeader(request), and inside it ensureAppIsEmbeddedIfRequired throws redirect(await api.auth.getEmbeddedAppUrl(...)) whenever embedded=1 is missing from the query. Absolute, cross origin, and you are back at the box.
That fits your symptom exactly. First load works because Shopify puts id_token, shop and host in the URL, so the server authenticates off the URL param and never needs the header. Every client navigation after that has neither the param nor the header.
App Bridge is what adds the header, so check it is actually alive in the frame. In the console type window.shopify. Undefined means it never loaded. Then view source and look at the tag. AppProvider renders it as <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js" data-api-key={apiKey}> and apiKey comes from your loader returning process.env.SHOPIFY_API_KEY. On Fly that has to exist as a runtime secret. If you only set it as a build arg, the tag renders with an empty data-api-key and App Bridge never initialises. fly ssh console -C "printenv SHOPIFY_API_KEY" settles that in one command.
On vite, you did not need the downgrade. @react-router/dev 7.18.2 declares vite ^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0, so 7.3.6 is fine and so was 8.
If you post anything back, the two useful things are the request headers on _root.data (is Authorization there) and the response body (which URL). Those answer it without anyone needing to read your package.json.