Shopify Mobile App: Save button becomes unresponsive after selecting a collection until Contextual Save Bar is re-triggered

Hi Shopify team,

We’ve encountered an issue that appears to occur only in the Shopify Mobile App.

Steps to Reproduce

  1. Open an embedded app page with editable form fields.

  2. Make a change to the form.

  3. Open the Collection Picker.

  4. Select a collection and return to the page.

  5. A Save / Discard navigation bar appears in the Shopify Mobile App.

  6. Tap Save.

Actual Result

  • Tapping Save does nothing.

  • No save action is triggered.

  • The page remains in an unsaved state.

Workaround

  1. Tap Discard.

  2. Confirm Discard Changes.

  3. The standard Contextual Save Bar then appears.

  4. Tap Save from the Contextual Save Bar.

At this point the save works successfully.

Expected Result

The initial Save action displayed after returning from the Collection Picker should trigger the save operation normally without requiring the user to discard changes first.

Additional Information

  • Reproducible only in the Shopify Mobile App.

  • Not reproducible in Shopify Admin on desktop.

  • The save functionality itself appears to be working because saving succeeds once the Contextual Save Bar is displayed.

  • The issue appears related to synchronization between the Collection Picker flow and the mobile app’s Save/Discard navigation controls.

Has anyone else experienced similar behavior with Collection Picker, App Bridge, or Contextual Save Bar interactions in the Shopify Mobile App?

I’ve attached a screen recording demonstrating the issue.

https://docs.google.com/videos/d/1me63IHzaiSFE5MiwQ1LMT8V6KAiSravn7ooyj-lysZU/edit?usp=sharing

Hey Pallavi - thanks for reaching out and sharing the screen recording, that helps a lot.

I see you’ve opened a support ticket for this as well and I haven’t been able to reproduce the issue yet. The Save button flashing on tap tells us the press is registering, so it would be great to see some of the code behind it.

Could you please share in the support ticket:

  • the Save button markup inside your <SaveBar>, and its onClick handler in full
  • whatever runs when you return from the Collection Picker, up to the point where Save should become usable

With this context, we should be able to dig into this properly.

Thanks!

Hi,

Thanks for looking into this. Below is the code you requested.

Environment

  • @shopify/app-bridge-react: ^4.2.10
  • App Bridge loaded via CDN: https://cdn.shopify.com/shopifycloud/app-bridge.js
  • Affected page: Dynamic QR Code edit/create (/dynamic/:qrId)
  • Reproducible only in the Shopify Mobile app (iOS/Android). Desktop Admin works correctly.

1. Save button markup inside <SaveBar>, and its onClick handler in full

We use a shared wrapper component, RenderContextualSaveBar:

// web/frontend/components/RenderContextualSavebar.tsx

import { SaveBar } from ‘@shopifyshopifyshopifyshopify/app-bridge-react’;import React, { useCallback, useState } from ‘react’;import { useTranslation } from ‘react-i18next’;

const RenderContextualSaveBar = ({open = false,isLoading,isDisabled,saveHandler,discardHandler,}) => {const { t } = useTranslation();const [isPending, setIsPending] = useState(false);const effectiveLoading = isLoading || isPending;

const onSaveClick = useCallback(async (e: React.MouseEvent) => {e.preventDefault();if (effectiveLoading || isDisabled) return;setIsPending(true);try {await Promise.resolve(saveHandler());} finally {setIsPending(false);}},[effectiveLoading, isDisabled, saveHandler],);

const onDiscardClick = useCallback((e: React.MouseEvent) => {e.preventDefault();if (effectiveLoading) return;discardHandler();},[effectiveLoading, discardHandler],);

return (

<buttontype=“button”variant=“primary”disabled={effectiveLoading || isDisabled}aria-busy={effectiveLoading}loading={effectiveLoading ? ‘’ : undefined}onClick={onSaveClick}

{effectiveLoading ? null : t(‘common.save’)}

<buttontype=“button”disabled={effectiveLoading}onClick={onDiscardClick}

{t(‘settings.discardChanges’)}

);};


On the Dynamic QR Code page, the save bar is wired like this:

// web/frontend/pages/DynamicQRCode.tsx

const handleSave = async () => {
try {
const savedId = await dynamicHook.saveQRCode();
if (savedId && (!qrId || String(qrId) === 'new')) {
redirectDynamicToId(savedId);
}
enableReviewPrompt();
} catch (error) {
console.error(error);
throw error;
}
};

const handleDiscard = () => {
if (dynamicHook.originalFormData) {
dynamicHook.handleResetErrors();
dynamicHook.setFormData(dynamicHook.originalFormData);
}
};

const shouldShowContextSaveBar = useMemo(() => {
return (
hasInitiallyLoaded &&
hasChanges &&
!dynamicHook.isLoading &&
dynamicHook.originalFormData
);
}, [hasInitiallyLoaded, hasChanges, dynamicHook.isLoading, dynamicHook.originalFormData]);

const isSaveBarSaveDisabled = useMemo(() => {
// Intentionally only disabled while saving — not when form is invalid,
// so validation errors can surface on Save click.
return dynamicHook.isSaving;
}, [dynamicHook.isSaving]);

<RenderContextualSaveBar
open={Boolean(shouldShowContextSaveBar)}
discardHandler={handleDiscard}
isLoading={dynamicHook.isSaving}
saveHandler={handleSave}
isDisabled={isSaveBarSaveDisabled}
/>

`saveHandler` ultimately calls `saveQRCode()` in our hook, which validates and POSTs to `/api/dynamic-qr-codes/:id`.

2. What runs when returning from the Collection Picker (up to Save becoming usable)

`leaveConfirmation()` is not called anywhere in this path.It is only used for navigation (back button, route changes, post-save redirect). The collection picker flow is separate.

Step 1 — Open picker

// web/frontend/components/ShopifyResourcePicker.tsx

const openResourcePicker = useCallback(async () => {
setState((prev) => ({ ...prev, loading: true }));
try {
const selection = await shopify.resourcePicker({
type: pickerOptions.resourceType, // 'collection' for Collection type
multiple: pickerOptions.selectMultiple,
filter: { variants: pickerOptions.showVariants },
selectionIds: state.selectedResources,
});

if (selection) {
const processedResources = processResources(selection);
setState((prev) => ({
...prev,
selectedResources: processedResources,
loading: false,
}));
}
} catch (error) {
console.error('Error selecting resources:', error);
setState((prev) => ({
...prev,
loading: false,
errors: [...prev.errors, 'Failed to select resources'],
}));
}
}, [pickerOptions, state.selectedResources, processResources, onResourceChange]);

Step 2 — Propagate selection to form state

When `selectedResources` updates, this effect fires:

// web/frontend/components/ShopifyResourcePicker.tsx

useEffect(() => {
onResourceChange(state?.selectedResources);
}, [state?.selectedResources]);

`onResourceChange` is passed from the page as `handleResourceChange`:

// web/frontend/hooks/useDynamicQRCodeFunctions.ts

const handleResourceChange = useCallback(
(resource: any) => {
updateDestinationAndResource(
{ ...formData, selected_resources: resource },
context.store?.domain,
);
handleChange('selected_resources', resource);
if (resource?.length > 0) {
context.handleErrorsChange('selected_resources', '');
}
},
[formData, context.store?.domain, context.handleErrorsChange, handleChange],
);

Step 3 — Dirty state → SaveBar opens

After `formData` changes, this effect on the page sets `hasChanges = true`:

// web/frontend/pages/DynamicQRCode.tsx

useEffect(() => {
if (!hasInitiallyLoaded || !dynamicHook.originalFormData) {
setHasChanges(false);
return;
}
// ... normalize and compare formData vs originalFormData ...
setHasChanges(!_.isEqual(formNormalized, originalNormalized));
}, [dynamicHook.formData, dynamicHook.originalFormData, hasInitiallyLoaded]);

That makes `shouldShowContextSaveBar` true and `<SaveBar open={true}>`.

At this point Save should be usable: the button is not disabled (only `isSaving` disables it), and `onClick` should call `handleSave` → `saveQRCode()`.

3. Where `leaveConfirmation()` is used (not in the picker path)

// Back navigation — DynamicQRCode.tsx
const handleBackAction = useCallback(async () => {
await app.saveBar.leaveConfirmation();
navigate('/dynamic');
}, [app.saveBar, navigate]);

// Global route blocker — App.tsx
useEffect(() => {
if (blocker.state === 'blocked') {
app.saveBar.leaveConfirmation().then(() => {
blocker.proceed();
}).catch(() => {
blocker.reset();
});
}
}, [blocker, app.saveBar]);

// Post-save redirect for new QR codes — DynamicQRCode.tsx
const redirectDynamicToId = useCallback((id: string) => {
const doNavigate = () => { /* ... */ navigate(`/dynamic/${id}`); };
const leavePromise = app.saveBar.leaveConfirmation?.();
if (leavePromise) leavePromise.finally(doNavigate);
else doNavigate();
}, [navigate, app.saveBar]);

None of these run when the Collection Picker closes.

4. Observed behavior on Shopify Mobile

1. Edit a form field → open Collection Picker → select a collection → return to the page.
2. A Save/Discard navigation bar appears in the mobile app.
3. Tapping Save flashes (press registers) but `onSaveClick` / `saveQRCode()` does not appear to run — no API call, page stays dirty.
4. Workaround: Tap Discard → confirm → the standard Contextual Save Bar then appears → Save works normally.

This suggests the first Save/Discard UI shown after the picker may not be wired to our `<SaveBar>` button handlers, even though Save works once the proper Contextual Save Bar is visible.


Thanks for sending over the code @Pallavi_Singh, that made all the difference

As mentioned in your support ticket, we were able to reproduce what you’re seeing, and it isn’t coming from your app’s code. The Save button isn’t actually frozen. What’s happening is the Collection Picker doesn’t fully close on the Shopify mobile app. After you pick a collection and come back, the picker’s header (the close icon and the Select collection bar) stays layered on top of your page and intercepts your taps. The contextual Save sits underneath it, so your tap lands on that leftover header instead of the button. That’s why it reads as Save doing nothing until you discard and re-trigger the bar, since that interaction clears the stuck header.

To rule out your SaveBar setup, we rebuilt the flow in a bare test page with nothing but a single resourcePicker call. No SaveBar, no form, no state wiring at all. The stuck picker header showed up there too, so this is a platform-side behavior in App Bridge’s resource picker on the mobile app, not something in your implementation.

A couple of practical takeaways:

  • You don’t need to migrate your <SaveBar> or change anything in your app. It isn’t the cause.
  • For now, the discard-then-Save sequence you already found is the way to push the save through on mobile.

We’ve flagged this with the team that looks after App Bridge so it can be fixed at the source, and I’ll update this thread as that moves.

Hope this helps!

Hi @Pallavi_Singh

I cannot reproduce this bug. I notice your video show older version of Shopify Mobile. May I know which version you are using? Also, can you try the latest version? Thanks.