Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3236556
build: upgrade to Expo SDK 58 (React Native 0.88, React 19.3)
koniz-dev Oct 19, 2026
3f6358e
fix: adapt the app to Expo Router 58 and React Native 0.88's strict t…
koniz-dev Oct 19, 2026
b90bc84
test: move to React Native Testing Library 14's async APIs
koniz-dev Oct 19, 2026
1023154
fix(web): export a single-page app explicitly
koniz-dev Oct 19, 2026
9c34427
docs, e2e: Expo SDK 58 versions, and Expo Go 58.0.4 in the Android e2e
koniz-dev Oct 19, 2026
51d8e04
fix(nav): keep one Home when the session guards send a user Home
koniz-dev Oct 19, 2026
b0c6098
docs: undeclared tabs, web redeploys with route-split JavaScript
koniz-dev Oct 19, 2026
fad66ff
fix(nav): return to the screen below when the session guards send a u…
koniz-dev Oct 19, 2026
60e0c35
docs: wording of the undeclared-tab and web redeploy notes
koniz-dev Oct 19, 2026
83b66e5
fix(nav): undo only the guards' replacement, not navigation to a tab
koniz-dev Oct 19, 2026
c4ee546
fix(nav): undo the guards' redirect only right after the session changed
koniz-dev Oct 19, 2026
ec4d88a
test(nav): cover the session-change hook's conditions; drop a redunda…
koniz-dev Oct 19, 2026
c517314
docs(conventions): the guards return a user to the screen below; repl…
koniz-dev Oct 19, 2026
5fb2731
docs(conventions): go Home with dismissTo after signIn/signOut, and d…
koniz-dev Oct 19, 2026
050b01a
fix(nav): clear the session flag when a guarded group leaves for the …
koniz-dev Oct 19, 2026
a93a9fe
docs(conventions): scope the post-sign-in advice to screens in the gu…
koniz-dev Oct 19, 2026
ce5909e
docs(conventions): return to a tab with dismissTo from a screen over …
koniz-dev Oct 19, 2026
b6d6379
docs(conventions): wording of the tab-return and guard sentences (R10…
koniz-dev Oct 19, 2026
dd02672
chore(nav): point useNoExtraHome to the upstream report (expo/expo#51…
koniz-dev Oct 20, 2026
56e441d
test(session): cover the cold-start restore guard and the unmount guards
koniz-dev Oct 25, 2026
e5c4243
fix(deps): upgrade zod to 4.6.7 for GHSA-mrhx-c7xc-m3w5
koniz-dev Oct 25, 2026
2204ac8
Merge main (zod fix 1ecec29) into sdk-58
koniz-dev Oct 25, 2026
d1eb09f
ci: add a manual EAS Build workflow, inert without EXPO_TOKEN
koniz-dev Oct 25, 2026
2c81883
feat(scripts): add npm run new-feature to scaffold a feature
koniz-dev Oct 25, 2026
396b833
docs(recipes): add a certificate pinning recipe
koniz-dev Oct 25, 2026
fc671e2
docs(recipes): add a recipe for a second language
koniz-dev Oct 25, 2026
ab4d519
ci(e2e): run the Android flows weekly where E2E_ANDROID_SCHEDULE is set
koniz-dev Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
- name: Check docs (snippets compile, paths and scripts exist)
run: |
# @recipes remove-block-start
# The state-management recipes and the TanStack Query example need
# The recipes and the TanStack Query example need
# their libraries to compile. They are pinned, with their own
# dependencies, in docs/recipes/package-lock.json (not the app's
# package.json) and linked into node_modules for tsc.
Expand Down
55 changes: 47 additions & 8 deletions .github/workflows/e2e-android.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
name: E2E (Android, manual)
name: E2E (Android)

# Runs the Maestro flows in .maestro/ on an Android emulator, in Expo Go or
# in a native debug build of the development variant.
# Manual only: an emulator run takes far longer than the verify job.
# Not on push: an emulator run takes far longer than the verify job. It runs
# when dispatched, and weekly (in Expo Go) in a repository that sets the
# variable E2E_ANDROID_SCHEDULE to true, so that a change outside the
# lockfile (the runner image, the emulator, a download) can't break the
# flows unnoticed. Without the variable the weekly run is skipped and costs
# nothing (docs/testing.md#end-to-end-flows-maestro).
on:
schedule:
- cron: '17 3 * * 1'
workflow_dispatch:
inputs:
app:
Expand All @@ -19,15 +26,18 @@ permissions:

jobs:
e2e-android:
name: Maestro on Android emulator (${{ inputs.app }})
name: Maestro on Android emulator (${{ inputs.app || 'expo-go' }})
if: github.event_name != 'schedule' || vars.E2E_ANDROID_SCHEDULE == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
# A scheduled run has no inputs: it tests Expo Go.
APP: ${{ inputs.app || 'expo-go' }}
MAESTRO_VERSION: '2.11.0'
MAESTRO_SHA256: '5384593cb4e7a106489e75a821d157dd43f4e438df6bc308b72e82c685e1283a'
# Expo Go for the project's SDK; update both on an SDK upgrade.
EXPO_GO_VERSION: '57.0.9'
EXPO_GO_SHA256: '14c18828a3d04e43922245920bd941b978fd1c7a5aec88a0188032f0bb4972ce'
EXPO_GO_VERSION: '58.0.4'
EXPO_GO_SHA256: 'bd3d36794f3e060a1dd93bec7affdceb516294c1b33abbf63d2994f5cc66b3a6'
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -57,7 +67,7 @@ jobs:
echo "$HOME/maestro/bin" >> "$GITHUB_PATH"

- name: Download Expo Go (pinned, checksum verified)
if: inputs.app == 'expo-go'
if: env.APP == 'expo-go'
run: |
sdk="$(node -p "require('expo/package.json').version.split('.')[0]")"
if [ "${EXPO_GO_VERSION%%.*}" != "$sdk" ]; then
Expand All @@ -71,7 +81,7 @@ jobs:
# bundle from Metro on localhost:8081 (adb reverse), so the flows open
# no link.
- name: Build the native debug app (development variant)
if: inputs.app == 'native-debug'
if: env.APP == 'native-debug'
run: |
npm run prebuild:development -- --platform android
# Gradle downloads the NDK and plugins; a download that breaks off
Expand Down Expand Up @@ -105,6 +115,35 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
retention-days: 14
name: e2e-android-${{ inputs.app }}-results
name: e2e-android-${{ env.APP }}-results
path: e2e-results/android
if-no-files-found: ignore

# A scheduled failure also opens an issue (or comments on the open one), so
# it is seen without watching the Actions tab. A job of its own: only it can
# write issues, and it runs none of the project's code.
report-failure:
name: Report the scheduled failure
needs: e2e-android
if: failure() && github.event_name == 'schedule'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open or update the failure issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
title='Weekly E2E (Android) run failed'
number="$(gh issue list --state open --search "\"$title\" in:title" --json number,title \
--jq "map(select(.title == \"$title\")) | .[0].number // empty")"
if [ -n "$number" ]; then
gh issue comment "$number" --body "Failed again: $RUN_URL"
else
gh issue create --title "$title" --body "The weekly Maestro run on an Android emulator failed: $RUN_URL

Its Maestro results are attached to the run. Fix the cause, then dispatch the E2E (Android) workflow and close this issue when it passes."
fi
2 changes: 1 addition & 1 deletion .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ permissions:
jobs:
e2e-ios:
name: Maestro on iOS simulator (native-debug)
# Expo SDK 57 needs Xcode 26.4 or later.
# Expo SDK 58 needs Xcode 26.4 or later.
runs-on: macos-26
timeout-minutes: 120
env:
Expand Down
96 changes: 96 additions & 0 deletions .github/workflows/eas-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: EAS Build (manual)

# Builds the app on EAS Build with a profile from eas.json and can submit a
# production build to the stores. Manual only, and inert until the
# repository has an EXPO_TOKEN secret: without one the run succeeds with a
# notice saying how to enable it. See docs/app-config.md.
on:
workflow_dispatch:
inputs:
profile:
description: 'Build profile in eas.json'
type: choice
options:
- preview
- production
default: preview
platform:
description: 'Platform to build'
type: choice
options:
- android
- ios
- all
default: android
submit:
description: 'Submit the build to the stores (production only, with the submit profile in eas.json)'
type: boolean
default: false

permissions:
contents: read

# A build or submission is never cancelled midway: a newer run for the same
# profile and platform waits for the one in progress.
concurrency:
group: eas-build-${{ inputs.profile }}-${{ inputs.platform }}
cancel-in-progress: false

jobs:
build:
name: EAS Build (${{ inputs.profile }}, ${{ inputs.platform }})
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
# Pinned; raise it when eas.json needs a newer CLI.
EAS_CLI_VERSION: '24.13.0'
PROFILE: ${{ inputs.profile }}
PLATFORM: ${{ inputs.platform }}
SUBMIT: ${{ inputs.submit }}
steps:
- name: Check for EXPO_TOKEN
id: token
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [ -z "$EXPO_TOKEN" ]; then
echo "::notice title=EAS Build skipped::No EXPO_TOKEN secret. Create an access token at https://expo.dev/settings/access-tokens and add it as the repository secret EXPO_TOKEN (docs/app-config.md, EAS Build from GitHub Actions)."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi

- name: Check the inputs
if: steps.token.outputs.enabled == 'true' && inputs.submit && inputs.profile != 'production'
run: |
echo "::error::Only production builds are submitted: eas.json has a production submit profile, and preview builds are for internal distribution."
exit 1

- name: Check out repository
if: steps.token.outputs.enabled == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node.js
if: steps.token.outputs.enabled == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm

# EAS CLI reads app.config.ts, which needs the installed packages.
- name: Install dependencies
if: steps.token.outputs.enabled == 'true'
run: npm ci

- name: Build on EAS
if: steps.token.outputs.enabled == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
args=(build --platform "$PLATFORM" --profile "$PROFILE" --non-interactive)
if [ "$SUBMIT" = 'true' ]; then
args+=(--auto-submit)
fi
npx --yes "eas-cli@${EAS_CLI_VERSION}" "${args[@]}"
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent Instructions

This is an Expo (SDK 57) and React Native app: Expo
This is an Expo (SDK 58) and React Native app: Expo
Router, TypeScript strict, React Native Paper (Material Design 3), Jest and
React Native Testing Library, and Maestro end-to-end flows. Follow these
instructions when you change the code; the linked docs have the details.
Expand Down Expand Up @@ -65,7 +65,8 @@ placement, and `t()` are by review. Details:
default: `app/<name>.tsx` for `/<name>`, `app/(app)/<name>.tsx` to require
sign-in, or a file in `app/(tabs)/` for a tab. Add its strings to
`shared/i18n/en.ts` and its tests under `__tests__/features/<name>/`.
See [Adding a screen](docs/conventions.md#adding-a-screen) and
`npm run new-feature -- <name> [--route public|signed-in|tab]` scaffolds
all of these. See [Adding a screen](docs/conventions.md#adding-a-screen) and
[Navigation](docs/conventions.md#navigation).
- **An endpoint:** typed functions in `features/<name>/api/`, built on the
app client in `shared/http/api.ts`; load data in screens with `useFetch`
Expand Down Expand Up @@ -164,5 +165,9 @@ Keep commits small and single-purpose.

- [State Management](docs/recipes/state-management.md): store library
recipes.
- [Certificate Pinning](docs/recipes/certificate-pinning.md): public-key
pinning for the API hosts.
- [A Second Language](docs/recipes/localization.md): device locale,
fallback, and RTL.

<!-- @recipes remove-block-end -->
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# React Native Starter

An Expo (SDK 57) and React Native starter that is **production-ready by
An Expo (SDK 58) and React Native starter that is **production-ready by
configuration, not by installation**: the foundation (routing, session,
HTTP, storage, theming, error reporting, i18n, test setup) is in place, every
third-party service sits behind a typed seam with a no-op or console
Expand Down
2 changes: 2 additions & 0 deletions __tests__/app/appConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ describe('app.config.ts variants', () => {
expect(config.scheme).toBe(scheme);
expect(config.ios?.bundleIdentifier).toBe(bundleId);
expect(config.android?.package).toBe(bundleId);
// The web export is a single-page app, which the hosting docs describe.
expect(config.web?.output).toBe('single');
});

it('gives each variant a distinct bundle ID and scheme', () => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/app/configError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jest.mock('@/shared/config/env', () => {

describe('startup with invalid configuration', () => {
test('shows the configuration error screen instead of the app', async () => {
renderRouter('./app', { initialUrl: '/' });
await renderRouter('./app', { initialUrl: '/' });

expect(await screen.findByText('Configuration error')).toBeTruthy();
expect(screen.getByText('EXPO_PUBLIC_API_URL')).toBeTruthy();
Expand Down
10 changes: 5 additions & 5 deletions __tests__/app/initialRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@ import { unstable_settings } from '@/app/_layout';

describe('initial route', () => {
test('anchors the root stack on the tabs group', () => {
expect(unstable_settings.initialRouteName).toBe('(tabs)');
expect(unstable_settings.anchor).toBe('(tabs)');
});

test('/ renders the Home tab', async () => {
const app = renderRouter('./app', { initialUrl: '/' });
const app = await renderRouter('./app', { initialUrl: '/' });

expect(await screen.findByText('Session')).toBeTruthy();
expect(app.getPathname()).toBe('/');
expect(screen.queryByText('Welcome Back')).toBeNull();
});

test('/login renders the Login screen', async () => {
const app = renderRouter('./app', { initialUrl: '/login' });
const app = await renderRouter('./app', { initialUrl: '/login' });

expect(await screen.findByText('Welcome Back')).toBeTruthy();
expect(app.getPathname()).toBe('/login');
});

test('going back from a deep-linked /login returns to Home', async () => {
const app = renderRouter('./app', { initialUrl: '/login' });
const app = await renderRouter('./app', { initialUrl: '/login' });
await screen.findByText('Welcome Back');

expect(router.canGoBack()).toBe(true);
act(() => router.back());
await act(() => router.back());

expect(await screen.findByText('Session')).toBeTruthy();
expect(app.getPathname()).toBe('/');
Expand Down
2 changes: 1 addition & 1 deletion __tests__/app/integrationSetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('integration setup that throws', () => {
const logError = jest.spyOn(logger, 'error').mockImplementation(() => {});

// Loads app/_layout.tsx for the first time in this file.
renderRouter('./app', { initialUrl: '/' });
await renderRouter('./app', { initialUrl: '/' });

expect(await screen.findByText('Configuration error')).toBeTruthy();
expect(logError).toHaveBeenCalledWith(
Expand Down
2 changes: 1 addition & 1 deletion __tests__/app/nativeHead.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('screens in an iOS native build', () => {
['/showcase', 'Buttons'],
// @demo remove-block-end
])('%s renders', async (initialUrl, text) => {
renderRouter('./app', { initialUrl });
await renderRouter('./app', { initialUrl });
expect(await screen.findByText(text)).toBeTruthy();
expect(screen.queryByTestId('error-fallback')).toBeNull();
});
Expand Down
Loading
Loading