S-app-window top bar shows the heading and close button but not the slotted primary/secondary actions (Chrome for Windows)

Summary

A merchant on Chrome for Windows opens an <s-app-window> from my embedded app and the window’s top bar shows the heading and the close button, but neither of the two buttons slotted into <s-page> (a secondary-actions Cancel and a primary-action Add). The merchant can tick rows in the window but has no way to confirm or cancel except the X.

It is reproducible for the merchant, not random:

  • On a page reached by client-side navigation that has unsaved changes (a create form, save bar showing), the window opens without the two buttons every time. A full reload does not change it.
  • Once the merchant saves the record (the app client-side navigates to the record’s own page) and opens the same window from there, both buttons are present.

I cannot reproduce either case on Chrome for macOS: both buttons render, and the primary action’s label updates as rows are ticked.

Setup

  • Embedded app, App Home, polaris.js and app-bridge.js from cdn.shopify.com
  • React 19 + React Router 6 SPA. The window’s src is a route in the same SPA, loaded in the window’s iframe with app-bridge.js present
  • Inside the window:
<s-page heading="Add products" inlineSize="large">
  <s-button slot="primary-action" variant="primary" disabled={selected.size === 0 || undefined} onClick={add}>
    {selected.size > 0 ? `Add ${selected.size} products` : "Add products"}
  </s-button>
  <s-button slot="secondary-actions" onClick={cancel}>Cancel</s-button>
  ...table...
</s-page>
  • The primary action starts disabled and enables once a row is ticked. In the merchant’s screenshot a row is ticked and the top bar is still empty.
  • One detail that may matter: the window route first renders <s-page heading="Add products"> on its own while it fetches settings, then re-renders a few hundred ms later with the two slotted buttons. On macOS the top bar picks up the buttons on that second render. On the merchant’s machine it looks as if only the first render reached the top bar.
  • The parent opens the window with element.show() right after a React state update mounts the <s-app-window>, and calls shopify.saveBar.hide() on its own save bar just before.

Steps the merchant takes

  1. Open the app’s list page from the admin sidebar, click Create (client-side navigation to the create form, save bar appears)
  2. Click the control that opens the app window
  3. The window opens with its rows and heading, no Cancel, no Add
  4. Close with the X, save the form (client-side navigation to the record’s page), open the same window again: both buttons are there

Related threads

  • 26381 reports the same symptom: the primary action in an <s-app-window> sometimes not shown, with the heading falling back to the app name
  • 26912 and my earlier 37561 are the Chrome click-eventing issue on <s-page> actions after a client-side navigation. The same merchant hits that one too, on the same machine

Questions

  1. Is the window’s top bar expected to pick up slotted actions that are added after the initial <s-page> render, or should they be present on first paint?
  2. If first paint is required, is there a way to force the top bar to re-sync with the page’s slots?

Hey @Luke App Bridge watches for changes to the slotted actions after the initial render, so they shouldn’t need to be present on first paint. There isn’t a documented API to force the app window’s title bar to resync.

The difference between opening from the unsaved form and the saved record stands out, especially since a reload doesn’t help. The buttons being missing is also a different symptom from the click-handling issue in the earlier thread, so we haven’t established that the same workaround applies.

Could you share the parent handler that mounts and opens <s-app-window>, including the save-bar call, and the window route’s loading and loaded JSX? That would help check whether the <s-page> is replaced during loading and how mounting, hiding the save bar, and opening the window are sequenced. Please include the affected Chrome version too.

Thanks @Yoni-Shopify. Here is the code, trimmed to the relevant parts.

Parent page (React Router route /purchase-orders/:id, where :id is new on the create form)

const [picker, setPicker] = useState<{ token: string; src: string } | null>(null);

const handlePickProducts = useCallback(() => {
  const token = crypto.randomUUID();
  const query = new URLSearchParams({ token, currency, dateFormat });
  if (values.supplierId != null) query.set("supplierId", String(values.supplierId));
  if (values.destinationLocationId != null) query.set("locationId", String(values.destinationLocationId));
  if (onOrder.length > 0) query.set("selected", onOrder.join(","));
  setPicker({ token, src: `/windows/po-product-picker?${query}` });
  // The page's save bar stays up over an app window, so it goes down while the window is open
  shopify.saveBar.hide(SAVE_BAR_ID);
  // The element mounts with the state update above; show() on the next frame
  requestAnimationFrame(() => {
    const el = document.getElementById(PO_PICKER_WINDOW_ID) as (HTMLElement & { show?: () => void }) | null;
    el?.show?.();
  });
}, [values.lines, values.supplierId, values.destinationLocationId, currency, dateFormat]);

// The window's own hide event is the single exit: X, Add and Cancel all end here
useEffect(() => {
  if (picker === null) return;
  const el = document.getElementById(PO_PICKER_WINDOW_ID);
  if (!el) return;
  const onHide = () => {
    setPicker(null);
    if (locked) return;
    if (isNew || saveBarShownRef.current) shopify.saveBar.show(SAVE_BAR_ID);
  };
  el.addEventListener("hide", onHide);
  return () => el.removeEventListener("hide", onHide);
}, [picker, isNew, locked]);

// In the page JSX, after the form sections and the save bar
<ui-save-bar id={SAVE_BAR_ID}>
  <button type="button" variant="primary" onClick={handleSave} disabled={saving} />
  <button type="button" onClick={handleDiscard} />
</ui-save-bar>
{picker !== null && <s-app-window id={PO_PICKER_WINDOW_ID} src={picker.src} />}

Save bar state in the two cases: on the create form, shopify.saveBar.show(SAVE_BAR_ID) runs 500 ms after mount, so the bar is visible when the merchant clicks and hide() runs right before the window mounts and shows. On a saved record the bar is only shown once the form is dirty, so in the merchant’s working case it has never been shown and hide() is a no-op. That is the one difference I can see between the two paths.

Window route (/windows/po-product-picker, same SPA, its own iframe document with app-bridge.js and polaris.js present)

The <s-page> is replaced during loading: the loading one and the loaded one are different elements.

const PoProductPickerWindow = () => {
  const { data } = useQuery({ queryKey: queryKeys.shop, queryFn: () => apiClient("/shop") });
  if (!data) return <s-page heading={t("poPicker.heading")} inlineSize="large" />;
  return <PickerBody initialViews={data.data.shop.poPickerSavedViews} salesWindowDays={data.data.salesWindowDays} />;
};

const PickerBody = (props) => {
  ...
  return (
    <s-page heading={t("poPicker.heading")} inlineSize="large">
      <TitleBarButton
        slot="primary-action"
        variant="primary"
        disabled={selected.size === 0 || undefined}
        onClick={() => post({ type: "picked", token, variantIds: [...selected] })}
      >
        {selected.size > 0 ? t("poPicker.add", { count: selected.size }) : t("poPicker.addNone")}
      </TitleBarButton>
      <TitleBarButton slot="secondary-actions" onClick={() => post({ type: "cancelled", token })}>
        {t("common.cancel")}
      </TitleBarButton>
      <s-section padding="none">...filter bar and table...</s-section>
    </s-page>
  );
};

TitleBarButton is a plain <s-button> with the click handler attached natively, the mitigation from thread 26912: no React onClick, one addEventListener("click") in an effect, handler held in a ref.

export const TitleBarButton = ({ onClick, children, ...rest }) => {
  const elementRef = useRef<HTMLElement | null>(null);
  const handlerRef = useRef(onClick);
  handlerRef.current = onClick;
  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;
    const listener = (event: Event) => {
      if (element.hasAttribute("disabled")) return;
      handlerRef.current(event);
    };
    element.addEventListener("click", listener);
    return () => element.removeEventListener("click", listener);
  }, []);
  return <s-button ref={elementRef} {...rest}>{children}</s-button>;
};

So the sequence inside the window document is: React mounts <s-page heading="Add products"> with no children; /shop resolves (100 to 300 ms warm, longer cold); React unmounts that page and mounts a new <s-page> with the same heading, the two slotted buttons and the body. On macOS Chrome the top bar follows the second page fine: the heading stays, both buttons appear, and the primary label updates as rows are ticked.

Chrome version: the merchant has only said Chrome on Windows so far. I will ask the merchant for the exact build and add it here.

App Bridge attaches the app window’s observer to the <s-page> it finds. If that happens while your loading page is mounted, replacing it can leave the observer watching the old element and missing changes to the new page.

Could you keep one outer <s-page> mounted throughout loading, and have PickerBody return just the slotted buttons and content without its own <s-page> wrapper? The buttons can still be added after settings load. Keep the save-bar handling unchanged for this test.

Then retry the unsaved create-form flow on the affected Windows browser. Do both buttons appear, and does the Add label update as rows are selected?

Thanks @Yoni-Shopify, that fits what the merchant sees. I have made that change: one <s-page> is mounted for the window’s whole life, and the body returns the slotted buttons and the content inside it with no page of its own. Save-bar handling is untouched.

It goes out in the next release. I will then ask the merchant to retry the unsaved create-form flow on the affected Windows Chrome and post the result here, along with the Chrome build.