Skip to content

WI-023: Expo scaffold

WI-023: Expo scaffold

Problem

There is no mobile app. WI-030 builds collection management across API, web and mobile simultaneously, and discovering then that Metro cannot resolve a workspace package would block a vertical slice rather than a scaffold.

The scaffold exists to find those problems while they are cheap.

In scope

  • apps/mobile — Expo SDK 57, Expo Router, TypeScript.
  • Metro configured for a pnpm workspace, which is the part that actually breaks.
  • Two routes, one of them dynamic, so file-based routing is exercised rather than assumed.
  • Pure display helpers consuming @tabletop/shared, with tests.
  • A runtime import of a shared value, not just a type.
  • expo export --platform web wired into the gate as the app’s build.
  • A bundle budget.

Out of scope

  • Any network call. The API does not exist (WI-012, blocked on WI-H06).
  • Authentication — WI-013.
  • Native builds, EAS, store metadata. Those need paid accounts (WI-H02, WI-H03) and are deliberately deferred so the billing clock does not start early.
  • Component rendering tests. Those need a native-aware runner and belong with the first real screen.

Why expo export --platform web is the build

It is the only Expo build that runs without Xcode or the Android SDK, so CI can do it on a free runner. More usefully, it exercises the thing that actually breaks in a monorepo: Metro resolving workspace packages through pnpm’s symlinks. A native build would not test that any harder.

The type-only trap

import type is erased before bundling. A scaffold that only imports types from @tabletop/shared will bundle cleanly while every runtime import from that package is broken — and the failure surfaces at WI-030, in a vertical slice, rather than here.

So the [slug] route imports and calls slugSchema at runtime. That import is load-bearing: remove it and the bundle stops proving anything.

Definition of done

  • apps/mobile lints, typechecks, tests and builds in the workspace gate.
  • expo export --platform web succeeds and emits the dynamic route as well as the static ones.
  • A runtime value from @tabletop/shared is imported and used, so the contract is proven rather than assumed.
  • Display helpers cover the nullable columns the catalogue actually has — a game with no designers, no player count and no playing time renders words rather than null or NaN.
  • Metro resolution is fixed in metro.config.js and the reason is written down, because every line of it looks removable.
  • The bundle has a budget that fails the build when exceeded.
  • Gate green.

Verification

Terminal window
pnpm --filter @tabletop/mobile test
pnpm --filter @tabletop/mobile build
pnpm gate

What this found

pnpm needs hierarchical lookup; the common advice says to disable it. disableHierarchicalLookup circulates as the fix for duplicate React in hoisted Yarn and npm monorepos. Under pnpm a package’s dependencies live in its sibling directory inside .pnpm, and hierarchical lookup is precisely how they are found. Setting it broke resolution of expo-modules-core from expo itself.

Transitive peers must be declared. @expo/router-server requires expo-font, which pnpm does not hoist, so nothing could reach it. Declared explicitly in apps/mobile.

Metro does not map .js specifiers onto .ts sources. Workspace packages ship TypeScript and their main points at src/index.ts; Node ESM requires the extension, so they import ./schemas.js while the file is schemas.ts. Node and tsx map that, Metro resolves the literal path and fails. A resolveRequest shim handles it, scoped to packages/.

The contract carries the ORM. One runtime value from @tabletop/shared grew the bundle from 1.1MB to 1.79MB, pulling in pg-core and drizzle-zod. That is a fork ADR-0008 did not anticipate, recorded as ADR-0022 and deliberately left unresolved — it changes how every client consumes the contract, which is not a decision to take while building a scaffold. The cost is fenced by the bundle budget in the meantime.