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.