The new POS navigation API is unusable

Calling POS shopify.cart.setCustomer with a non-existing id leads to an uncaught exception in the native POS code.

Example snippet:

try {
  console.log("Calling setCustomer")
  await shopify.cart.setCustomer({
    id: 1,
  });
} catch (e) {
  console.error("setCustomer exception caught")
}

In the dev tools console I can see that “Calling setCustomer“ is printed, but “setCustomer exception caught“ isn’t. Instead of the expected error, I see this exception

load.html:10 Uncaught (in promise) Error: Customer not found - id: 1
    at construct (native)
    at apply (native)
    at _construct (address at index.android.bundle:1:650692)
    at Wrapper (address at index.android.bundle:1:650344)
    at construct (native)
    at _callSuper (address at index.android.bundle:1:2419705)
    at POSError (address at index.android.bundle:1:2419958)
    at construct (native)
    at _callSuper (address at index.android.bundle:1:5672019)
    at CustomerNotFoundError (address at index.android.bundle:1:5672344)
    at ?anon_0_ (address at index.android.bundle:1:5658726)
    at next (native)
    at asyncGeneratorStep (address at index.android.bundle:1:1130157)
    at _next (address at index.android.bundle:1:1130415)
    at callReaction (address at index.android.bundle:1:553154)
    at anonymous (address at index.android.bundle:1:553420)
    at flush (address at index.android.bundle:1:558771)
    at tryCallOne (address at InternalBytecode.js:1:1180)
    at anonymous (address at InternalBytecode.js:1:1874)

This works fine on version 2025-07, but not 2026-01.

NOTE: while I can only inspect this error on Android, I see the same symptoms on my iPad.

For context, my app enables merchants to add customers to the POS cart by scanning their barcode. The majority of merchants use barcodes issued by the app. Such barcodes have customer IDs encoded. However, some merchants issue their own barcodes. Currently, in production, the app calls setCustomer, and if it throws, then the app falls back to its database lookup.

I’d like to avoid fetching the customer ID via the Shopify admin API before calling shopify.cart.setCustomer, as it would be an additional redundant request. The code in shopify.cart.setCustomer already checks if the customer exists.

Would it be possible to get this fixed in version 2026-04?

It appears that setCustomer never resolves, even when called with an existing customer ID.

Example snippet

      await shopify.cart.setCustomer({
        id: numericId, // ID of an existing customer
      });
      shopify.toast.show(`Customer set successfully`);

The toast doesn’t pop up

Hi Galmis, thanks for flagging this. We’ll provide an update here once we’ve resolved that error.

Regarding the issue where setCustomer doesn’t resolve, I wasn’t able to reproduce it. The promise resolves with undefined , the customer gets set to the cart, and the toast appears with this simple setup:

async function testValidCustomer() {
    try {
      console.log("[POS Extension] Calling setCustomer with valid customer");
      const result = await shopify.cart.setCustomer({ id: 123 });
      console.log("[POS Extension] setCustomer resolved:", result);
      shopify.toast.show("Customer set successfully");
    } catch (e) {
      console.error("[POS Extension] setCustomer exception caught:", e);
    }
  }

Hi @Paige-Shopify

Thanks for looking into this.

Sorry for leaving some details out. The issue appears to be more subtle. Please see the snippet below.

export function Example() {
  useEffect(() => {
    const unsubscribeScanner = shopify.scanner.scannerData.current.subscribe(
      async ({ data }) => {
        if (!data) return;

        shopify.toast.show(`Scanned: "${data}"`);
        navigation.back();
        // It works when calling navigation.navigate instead of navigation.back. Why is navigate async, but back is not?
        // await navigation.navigate("/");

        try {
          await shopify.cart.setCustomer({
            id: Number(data),
          });
          shopify.toast.show(`Customer set successfully`);
        } catch {
          shopify.toast.show(`Error setting customer`);
        }
      }
    );

    return () => unsubscribeScanner();
  }, []);

  // Go to the camera page and wait for the timeout - it works as expected
  // useEffect(() => {
  //   const timeout = setTimeout(async () => {
  //     navigation.back();

  //     try {
  //       await shopify.cart.setCustomer({
  //         id: <customer ID here>,
  //       });
  //       shopify.toast.show(`Customer set successfully`);
  //     } catch {
  //       shopify.toast.show(`Error setting customer`);
  //     }
  //   }, 5000);
  //   return () => clearTimeout(timeout);
  // }, []);

  if (navigation.currentEntry.url === "/camera") {
    return <CameraPage />;
  }

  return (
    <s-page heading="Default page">
      <s-button onClick={() => navigation.navigate("/camera")}>
        Open camera page
      </s-button>
    </s-page>
  );
}

function CameraPage() {
  useEffect(() => {
    shopify.scanner.showCameraScanner();
    return () => {
      shopify.scanner.hideCameraScanner();
    };
  }, []);

  return <s-page heading="Camera page"></s-page>;
}

setCustomer doesn’t resolve when navigating back after a camera scan event. This is the actual issue. I think the error in the description is a red herring.

It works fine when calling await navigation.navigate(“/”) instead of navigation.back(), but it adds an entry to the navigation history stack, which I’d like to avoid. I’m curious why navigate returns a promise, but back doesn’t.

Interestingly, it also seems to work fine when navigating back and setting a customer after a timeout instead of a scan event. I’m still trying to wrap my head around how the new navigation works. I’ve noticed that sharing global state between views no longer works. Example:

export function ExampleTwo() {
  const [count, setCount] = useState(0);

  if (navigation.currentEntry.url === "/two") {
    return (
      <s-page heading="Two page">
        <s-button onClick={() => navigation.back()}>Go back</s-button>
        <s-text>Count: {count}</s-text>
        <s-button onClick={() => setCount((c) => c + 1)}>Increment</s-button>
      </s-page>
    );
  }

  return (
    <s-page heading="Page one">
      <s-button onClick={() => navigation.navigate("/two")}>
        Go to page two
      </s-button>

      <s-text>Count: {count}</s-text>
      <s-button onClick={() => setCount((c) => c + 1)}>Increment</s-button>
    </s-page>
  );
}

When navigating to page two, the count always resets. I wonder if setCustomer doesn’t resolve because it’s being called by the no longer active view? Whereas the timeout example works, because the timeout was initiated by the initial view.

It’d be really nice if there was any documentation explaining how it all works under the hood.

Thanks so much for the detailed context, exactly what we needed!

Looks like there are a few issues here:

  1. Uncaught exception when Customer not found
  2. navigate.back doesn’t return a Promise
  3. Global state sharing no longer works

Thanks for flagging these issues with us. We’re not able to provide an ETA on when these will be fixed, but will provide an update in this thread once we do :slight_smile:

Hello, @Paige-Shopify.

I’m sorry to bother, but what’s the current status of this ticket? I’m experiencing another symptom of (what I think is) the same root cause - when navigating, a new runtime appears to be created, causing all sorts of weirdness.

Example snippet below:

function ExampleThree() {
  const lines = shopify.cart.current.value.lineItems;

  console.log(
    `\n ${new Date().toISOString()} ExampleThree, navigation.currentEntry.url -`,
    navigation.currentEntry.url
  );

  if (navigation.currentEntry.url === "/test") {
    return (
      <s-page heading="Test page">
        <s-button
          onClick={async () => {
            // NOTE: Ensure at least one line item is in the cart
            await shopify.cart.bulkAddLineItemProperties([
              {
                lineItemUuid: lines[0].uuid,
                properties: {
                  test: "1",
                },
              },
            ]);
            navigation.back();
          }}
        >
          Bulk add line item properties & go back
        </s-button>
      </s-page>
    );
  }

  return (
    <s-page heading="Initial page">
      <s-button onClick={() => navigation.navigate("/test")}>
        Go to test page
      </s-button>
    </s-page>
  );
}

To reproduce:

  1. Ensure there’s at least one line item in the cart
  2. Open the POS action extension
  3. Tap Go to test page
  4. Tap Bulk add line item properties & go back

Note that navigation.back was initiated (noticeable navigation animation), but the same page remains active. After that pressing the chevron button closes the extension instead of going back.

Beyond very basic use cases, the new POS navigation API is unusable. My understanding is that the React UI extensions will no longer be supported as of July 2026, which leaves us with slightly more than two months to migrate. This is very concerning.

Currently, the only alternative I can think of is to dynamically update the singleton page component and not use the new navigation API. POC below:

function ExampleFour() {
  const [screens, setScreens] = useState<ScreenName[]>([ScreenName.Cart]);

  console.log(
    `\n ${new Date().toISOString()} Example, screens -`,
    screens.join()
  );

  return (
    <s-page heading={screens[screens.length - 1]}>
      <s-stack rowGap="base">
        <s-button
          onClick={() => {
            setScreens((screens) => screens.slice(0, -1));
            // navigation.back();
          }}
        >
          Back
        </s-button>
        <s-button
          onClick={async () => {
            setScreens((screens) => [...screens, ScreenName.Line]);
            // await navigation.navigate("/");
          }}
        >
          Line
        </s-button>
        <s-button
          onClick={async () => {
            setScreens((screens) => [...screens, ScreenName.Cart]);
            // await navigation.navigate("/");
          }}
        >
          Cart
        </s-button>
        <s-button
          onClick={async () => {
            try {
              await shopify.cart.setCustomer({
                id: 1,
              });
            } catch (e) {
              console.error("set customer error -", e);
            }
          }}
        >
          Set customer
        </s-button>
      </s-stack>
    </s-page>
  );
}

All of the earlier mentioned issues go away and I’d be happy to rely on the custom navigation solution if it was possible to override the top left (Back/Close) button.

@Galmis, apologies for the delay. These issues should now be resolved. Can you confirm if you’re still encountering any of the reported issues?

Hi @Paige-Shopify.

No problem. Apparently, this is all by design, just not documented. I’m happy to close this.

Sorry I should have been more specific. I was referring to the uncaught exception that occurs with shopify.cart.setCustomer when it’s passed a non-existent ID. Let me know if you’re still encountering issues with it.

No problem, @Paige-Shopify.

All of the issues listed in this ticket (including the setCustomer one) are symptoms of the new navigation model. After rewriting some parts of my POS extensions, all of the listed issues went away.

Having something along the lines of “in pre 2025-10, your extension and all of its screens was mounted within a single JavaScript worker. Now, when you navigate, each screen is mounted in its own instance.“ in the migration docs would’ve saved a lot of time spent on debugging.

The specific issue was caused by navigation.back being called before setCustomer. This used to work because all the screens were mounted in the same JS worker, but that’s no longer the case. The fix for the specific symptom was to

  1. Store the camera scan result in storage and navigate back.
  2. On the target page, rehydrate the scan result from storage and call setCustomer. Since the method is now called by the active JS worker, it resolves.

Oh I see! Thanks for sharing what you found and what worked.

I agree it would be useful to have the change in mounting behavior called out in the docs, and it looks like JS_Goupil is already on it :slight_smile: