DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three facts hide our splash screen, and the two that would have been wrong to wait for

Munchable's first screen has to be right rather than fast, and also fast. It is a scanner: somebody has opened it in a supermarket aisle holding a packet, and the thing they are about to do depends on data that lives in four different places.

The app is at app.munchable.app (the same Expo codebase as the phone builds, served as a single page app) and the marketing site is at munchable.app. This post is about the twenty or so lines that decide what has to be true before either of them shows you anything.

The four things that load at boot

  1. Fonts. The app has its own typeface.
  2. The persisted health profile. Conditions, allergies, preferences, scan quota. Lives in AsyncStorage, never on our server.
  3. The ingredient knowledge overlay. Curated ingredient data that reaches the phone without an app release, cached locally.
  4. The account session. Restored from secure storage, then verified with the backend.

Two of those block the splash screen. Two of them must not. Getting that split wrong is not a performance opinion, it produces two specific and quite different bugs.

const ready = (fontsLoaded || !!fontError) && hydrated && taxonomyHydrated;

useEffect(() => {
  if (ready) void SplashScreen.hideAsync();
}, [ready]);

if (!ready) return null;
Enter fullscreen mode Exit fullscreen mode

Fonts, profile, overlay. No session.

Why the profile blocks

The profile is the app's entire premise. A hub rendered before it hydrates shows a user with no conditions, which is a different app: no verdicts, no scan counter, and an onboarding prompt for somebody who onboarded six months ago. That would then be replaced a frame later by their real profile. There is no version of that flash that is acceptable, so it blocks.

Note fontsLoaded || !!fontError. A font that fails to load must not hold the splash screen forever. Falling back to a system typeface is a cosmetic problem; a permanently black launch is a dead app.

Why the overlay blocks, which surprised me

The ingredient overlay is a cache with a bundled fallback, so the app works perfectly without it. My first instinct was that it should load in the background.

It blocks because it is one storage read that always resolves, and because a verdict computed with the bundled data and then recomputed with the overlay is a verdict that can visibly change on screen. In an app that answers a yes or no question about food someone is holding, a flicker between two answers is worse than a slightly longer cold start. It blocks, and it is allowed to block precisely because it cannot fail.

That is the rule that fell out of this: a step may block the splash screen only if it always resolves. Anything that can hang, retry or fail belongs in the second group, with a fallback.

Why the session must not block

Restoring the account session involves the network. Blocking on it means the splash screen is held hostage by a coffee shop captive portal, which is the exact moment someone is most likely to be scanning something.

So it runs after mount, unblocked:

// Restore any persisted account session + entitlement.
useEffect(() => {
  initAuth();
}, [initAuth]);
Enter fullscreen mode Exit fullscreen mode

And the overlay's freshness check hangs off it, in the right order:

useEffect(() => {
  if (authRestored && taxonomyHydrated) void refreshTaxonomy();
}, [authRestored, taxonomyHydrated, refreshTaxonomy]);
Enter fullscreen mode Exit fullscreen mode

Read the dependency list as a sentence: ask the server for newer ingredient knowledge only once we know who is asking and once the cached copy is in memory to compare against. Two separate effects rather than one sequential chain, because the local read has no business waiting for a network call that may never come back.

Then the flash of the sign-in screen

Making the session non-blocking creates a new problem immediately. There is now a window where the app is rendered and does not yet know whether anybody is signed in. Naively, "no account" means "show sign-in", so every cold start flashes the sign-in screen at a signed-in user.

The fix is that "restoring" is a third state, and it renders nothing:

export function useAccountGate(): React.ReactElement | null {
  const account = useAuth((s) => s.account);
  const restored = useAuth((s) => s.restored);
  if (!REQUIRE_ACCOUNT) return null;
  if (!restored) return <></>;
  if (!account) return <Redirect href="/(auth)/sign-in" />;
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Three branches, and the middle one is the whole point. An empty fragment is not laziness, it is the correct rendering of "we do not know yet". The unknown state is not the same as the negative state, and conflating them is the single most common way a boot sequence lies to a user.

The hook returns an element to render instead of the screen, which makes the call site two lines and puts it after the screen's other hooks so the hook order never changes:

const gate = useAccountGate();
if (gate) return gate;
Enter fullscreen mode Exit fullscreen mode

The routes the layout gate did not cover

Munchable's navigation is hub and spoke with no tab bar. Home is the hub, and Profile, Conditions, Allergies, History, Search and Recipes are spokes that push onto one stack. That whole group sits inside an (app) route group whose layout gates it:

if (REQUIRE_ACCOUNT && !restored) return null;
if (REQUIRE_ACCOUNT && !account) return <Redirect href="/(auth)/sign-in" />;
Enter fullscreen mode Exit fullscreen mode

One gate, every screen in the group covered. Tidy, and incomplete.

Four routes deliberately sit outside that group, because they are modal presentations rather than places: result, capture, menu and paywall. They are declared on the root stack, so the group's layout gate never runs for them. On a phone you reach them by tapping something inside the app, so in practice they are behind the gate.

In practice is not the same as by construction. Those routes are reachable directly by a munchable:// deep link, and in the web build by typing the URL. Hence the per-screen gate on each of them, and the comment that says why it exists rather than just what it does:

 * The hub and the `(app)` group already gate at the layout level; this also
 * covers the modal routes (capture / menu / paywall / result), which sit
 * outside that group and would otherwise be reachable by a `munchable://` deep
 * link (or a direct web URL) without an account.
Enter fullscreen mode Exit fullscreen mode

Failing closed

One more line, and it is the one I would copy into any app with a configurable backend:

export const REQUIRE_ACCOUNT =
  isSupabaseConfigured || process.env.EXPO_PUBLIC_ENV === 'production';
Enter fullscreen mode Exit fullscreen mode

The gate is on when a backend is configured, which lets a local development build with no keys run open and stay pleasant to work on. The second clause is the safety catch: in a production build the gate is on regardless, so a mis-built release with missing keys fails closed to the sign-in screen instead of silently opening the whole app to everybody.

A build configuration mistake and a security decision should never be the same variable. That || is what keeps them apart.

The shape of it

Written out, the boot sequence is four rules:

  • Block only on things that always resolve.
  • Never block on the network.
  • Treat "not known yet" as its own render state, never as the negative one.
  • Gate at the layout where the layout covers the route, and per screen where it does not.

None of that is clever, and all of it was learned by shipping the opposite. If you want to see the result, open app.munchable.app and watch what happens between the splash screen and the hub: the profile is already there, the sign-in screen does not flash past, and the check for newer ingredient data happens quietly after you can already use the app.

Top comments (0)