Skip to content

A failed inbox fetch is indistinguishable from having no inboxes: all fourteen useInboxStore consumers read only inboxes, and the error the store records is never read #1158

Description

@rawdaymx

Summary

inbox-store records why a fetch failed, and nothing reads it. Every consumer of useInboxStore selects inboxes and nothing else, so a failed request and a genuinely empty workspace produce the same state — inboxes: [] — and the same UI. The operator is told they have no channels when what happened is that the request did not come back.

The store itself is not the problem, and your own test says as much:

// apps/builder/src/features/inboxes/provider/__tests__/inbox-store.test.ts:122-138
test("still marks the store initialized when getAllInboxes fails", async () => {
  // getAllInboxes catches its own rejection and sets `error` without
  // rethrowing, so initialize's own try/catch never actually observes this
  // failure directly — but its `finally` unconditionally marks
  // `initialized: true` regardless, and getAllInboxes's error is still
  // visible on the shared `error` field.

"still visible on the shared error field" — visible to whom? We could not find a reader. The field is written on every failure path and selected by nobody.

Two things make it stick rather than pass: initialized: true is set in the finally, so initialize()'s own guard (if (initialized) return) means the provider never tries again for the life of that store; and getAllInboxes returns early and silently when workspaceId is falsy (:57), which also ends with initialized: true and zero requests.

Environment

Measured against upstream/main @ be7234b93bb47936e9469016ccb82e9b2aa765b7
How git grep over the repository at that commit, plus vitest runs of the repository's own store tests. The browser observations noted below are from a fork and are labelled as such
Node / pnpm / vitest v24.11.0 / 10.33.2 / 4.1.8
Date 2026-09-12

Evidence: fourteen readers, none of them reads error

$ git grep -n 'useInboxStore' be7234b93 -- apps/builder/src | grep -v 'inbox-store-context.tsx'
…/broadcasts/components/broadcast-flow-targets.tsx:86:      const inboxes = useInboxStore((state) => state.inboxes)
…/broadcasts/components/broadcast-inbox-multi-select.tsx:30:  const inboxes = useInboxStore((state) => state.inboxes)
…/broadcasts/components/broadcast-template-targets.tsx:33:  const inboxes = useInboxStore((state) => state.inboxes)
…/broadcasts/create-broadcast-form.tsx:263:                  const inboxes = useInboxStore((state) => state.inboxes)
…/contacts/contacts-list-action.tsx:68:      const hasContactScanInbox = useInboxStore((state) => …)
…/contacts/create-contact-form.tsx:59:               const inboxes = useInboxStore((state) => state.inboxes)
…/flows/react-flow/nodes/editor.tsx:165:                const inboxes = useInboxStore((s) => s.inboxes)
…/inboxes/components/get-inbox-url.tsx:30:      const { inboxes } = useInboxStore((state) => state)
…/inboxes/provider/inbox-hook.ts:43,72,100,115,130,145,163    (seven, all `state.inboxes`)

and the complementary search returns nothing:

$ git grep -n 'state) => state\.error\|s) => s\.error\|state\.loadingInboxes\|=> state\.initialized' \
    be7234b93 -- apps/builder/src
(no matches)

Fourteen call sites. Every one takes inboxes. None takes error, loadingInboxes or initialized.

InboxStoreProvider is mounted in ten places — the inbox page, contacts, contacts/import, contacts/scan, reflinks, both broadcast pages, the create-contact dialog, and flows/flow-detail.tsx:48 — so this covers most inbox-dependent surfaces in the product.

What that means where it is visible. In the flow editor, the step menu is built from inboxes alone (editor.tsx:173-186: nodeConfig.menus(t, { inboxes, … })). A failed fetch does not show an error; it silently removes the channel-specific step types from the menu the operator is choosing from. On the broadcast forms the target selectors come back empty. get-inbox-url.tsx has nothing to offer. In each case the product's answer to "we could not reach the server" is the same as its answer to "you have not connected anything yet".

Store-level behaviour, confirmed against the repository's own tests. pnpm exec vitest run src/features/inboxes/provider/__tests__/inbox-store.test.ts passes, including the case quoted above (initialized: true, error: "HTTP 500"). We also composed the :57 path through initialize() — the call the provider actually makes — with workspaceId: "", and measured initialized: true, inboxes: [], error: null, and listInboxesAuthenticatedAPI never called. So there is a second shape where the store ends up "ready" with nothing, and this one does not even leave an error behind.

Browser observation, and we want to be exact about what it is. On our fork we aborted the request carrying listInboxesAuthenticatedAPI and watched a channel-selection surface render with no indication of failure, against a control run on the same build where it rendered normally. That surface is our own component, which does not exist in this repository, so we are offering it as corroboration of the mechanism, not as a measurement of your UI. The mechanism is the part that is yours: inboxes: [] with the reason discarded.

One caveat on that run: the request goes out inside a POST /rpc/__batch__ with seven other procedures, so aborting it took all eight down. We could not isolate it at the network layer.

Why we think this is a defect rather than the intended design

The finally that sets initialized: true is clearly deliberate — it is tested, and the comment explains the reasoning. We are not asking for that to change; a store that retried forever on a hard failure would be worse.

What looks unintended is the other half. The comment justifies the unconditional initialized by pointing at the error field as the place the failure remains visible. That is a reasonable design — the store stays out of the retry business and hands the caller enough to decide. It only works if a caller looks, and after fourteen call sites none does. The field is written, asserted in tests, and dead in the product.

The :57 early return is the same shape one layer down: it is right for getAllInboxes as a public method, but initialize() funnels it into the same finally, so "we never asked" and "we asked and it worked" become indistinguishable to every reader.

Suggested fix

The cheap version is to give the consumers something to read and one place to read it. inbox-hook.ts already wraps useInboxStore for seven of the fourteen call sites, so it is the natural seam:

export const useInboxesState = () => ({
  inboxes: useInboxStore((s) => s.inboxes),
  error: useInboxStore((s) => s.error),
  loading: useInboxStore((s) => s.loadingInboxes),
  ready: useInboxStore((s) => s.initialized),
})

…and then, at minimum, let the surfaces that today render an empty list distinguish the three states. The flow editor's step menu is the one we would start with, since a silently shorter menu is the hardest for an operator to notice.

A smaller, separate improvement: have initialize() not mark the store initialized when getAllInboxes returned early for a falsy workspaceId, so a provider mounted before the workspace id is known can still initialize once it is. That one is a two-line change and independent of the rest.

We have not sent a patch because the interesting part is a product decision — what each of those surfaces should say — and that is yours to make. Happy to do the plumbing if the shape above is the one you want.

What we did not verify

  • We did not reproduce the failure against a deployed instance of this repository. The browser run described above used a fork's own component; everything about your consumers is read from the code at the commit named.
  • We did not check whether any consumer handles the empty-vs-failed distinction upstream of the store — for example by not mounting the provider until something else has confirmed connectivity. This is not found, not proven absent.
  • We did not look at whether loadingInboxes has the same "no readers" problem for a legitimately slow fetch, only that no consumer selects it.
  • We did not measure how often this happens in practice. The report is about the state being unrepresentable, not about its frequency.

Related issues

We searched open and closed issues and PRs for inbox store initialized, inboxes empty and channels create error; nothing describes this. PR #1068 introduced inbox-store.test.ts and the comment quoted above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions