@marktiderman/genesis-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,268 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The native app shell for Genesis Expo apps. `<GenesisNativeApp>` owns
4
+ everything above your screens, so the root layout is a few lines:
5
+
6
+ ```tsx
7
+ // app/_layout.tsx
8
+ import * as SplashScreen from "expo-splash-screen";
9
+ import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
10
+ import { Stack, router } from "expo-router";
11
+ import { GenesisNativeApp } from "@marktiderman/genesis-native";
12
+ import { brand, supabase, steps, persist, linkHold } from "../lib/app";
13
+
14
+ SplashScreen.preventAutoHideAsync();
15
+
16
+ export default function RootLayout() {
17
+ return (
18
+ <GenesisNativeApp brand={brand} supabase={supabase} splash={SplashScreen} steps={steps} persist={persist}
19
+ keyboardScroll={KeyboardAwareScrollView} links={{ hold: linkHold, onRelease: (path) => router.replace(path) }}>
20
+ <Stack />
21
+ </GenesisNativeApp>
22
+ );
23
+ }
24
+ ```
25
+
26
+ Pass the Supabase client in (make it with `createGenesisSupabase` from
27
+ `@marktiderman/genesis-supabase`); the shell never creates one.
28
+
29
+ ## What it does
30
+
31
+ From the outside in:
32
+
33
+ 1. **Last resort**: an error boundary above every provider, whose screen is
34
+ built from React Native primitives only. If a provider or the shell's own
35
+ set-up throws (a boot step id that clashes with the shell's, say), or the
36
+ `fatal` slot itself throws, this is what shows, and "Try again" starts over.
37
+ 2. **Theme**: `GenesisThemeProvider` with `syncNativeAppearance` on, so the
38
+ root owns the platform color scheme.
39
+ 3. **Keyboard**: `KeyboardScrollProvider` with your `keyboardScroll`, so
40
+ Genesis form screens scroll the focused field into view.
41
+ 4. **Data**: `GenesisProvider` over `createSupabaseProvider(supabase)` (or
42
+ your `provider`) and the shell's QueryClient.
43
+ 5. **Boot**: your `steps` (wipe a session left by a reinstall, hydrate stores,
44
+ load fonts), then reading the session, then restoring the query cache. The
45
+ splash stays up until all of them finish. Each step runs once; a step that
46
+ throws or passes its timeout (10 s, `bootOptions.timeoutMs`) shows the
47
+ fatal screen, and "Try again" resumes at that step, showing the `boot` slot
48
+ (a plain loading view) since the splash is gone. A step that timed out keeps
49
+ running (stop it when its `signal` aborts): a retry waits for it to settle
50
+ rather than running it twice at once, and counts it if it succeeded.
51
+ `bootOptions.minimumMs` keeps a fast boot from flashing the splash.
52
+ 6. **Errors**: a boundary around your screens and a global handler
53
+ (`ErrorUtils`) show the `fatal` slot instead of letting the app close.
54
+ "Try again" remounts the screens with fresh state. A second fatal error
55
+ while the fatal screen is up goes to React Native's default handler, which
56
+ ends the app so it restarts clean. Unhandled promise rejections never reach
57
+ `ErrorUtils` in React Native, so on Hermes the shell installs its rejection
58
+ tracker (`rejectionTracker`, `null` turns it off). `onError` gets all of
59
+ these, for reporting.
60
+ 7. **Session, offline included**: `useSession()` is `loading`, `signedIn` or
61
+ `signedOut`, with `userId` and `offline`. Offline with an expired access
62
+ token, Supabase's `getSession()` can spend about 30 seconds on a refresh
63
+ and then fail; the shell waits at most `sessionWaitMs` (5 s) and on a
64
+ timeout or a network error carries on with the session stored on the
65
+ phone (`offline: true`). Supabase's own events correct it once online.
66
+ 8. **Query**: `networkMode: "always"` so offline queries fail instead of
67
+ hanging. With `persist`, the query-key roots you list are saved to the
68
+ phone for 24 hours (`maxAge`), discarded when `buster` (your app version)
69
+ changes, and **scoped to the signed-in user**: the user id is part of what
70
+ is saved, a cache saved for anyone else is deleted instead of restored, and
71
+ the cache (in memory and on disk) is cleared whenever the user changes:
72
+ sign-out, a session that ended while the app was closed, an account switch.
73
+
74
+ ```ts
75
+ import { persistQueries } from "@marktiderman/genesis-native/persist";
76
+ export const persist = persistQueries({ storage: AsyncStorage, keys: ["clients", "me"], buster: version });
77
+ ```
78
+
79
+ Persistence is its own entry point: an app that saves nothing doesn't need
80
+ `@tanstack/query-persist-client-core` or
81
+ `@tanstack/query-async-storage-persister`. If you do, install them at the
82
+ same version as your `@tanstack/react-query`: they pin `@tanstack/query-core`
83
+ exactly, and two copies of it don't share a cache.
84
+
85
+ Pass `lifecycle={{ appState: AppState, netInfo: NetInfo }}` to refetch on
86
+ focus and follow the network.
87
+ 9. **Auth gate**: a signed-out user sees the `signedOut` slot, or your
88
+ screens when it is not set, for an expo-router layout that redirects:
89
+
90
+ ```tsx
91
+ // app/(app)/_layout.tsx
92
+ const { status } = useSession();
93
+ if (status === "signedOut") return <Redirect href="/sign-in" />;
94
+ return <Stack />;
95
+ ```
96
+
97
+ 10. **Deep links**: `normalizeLink` maps an incoming link to an app path for
98
+ expo-router's `+native-intent`. Hold every link you return; the shell
99
+ follows a held link only when the user had to sign in first, and drops it
100
+ when they were already signed in (the router opened it, so it doesn't
101
+ navigate twice):
102
+
103
+ ```ts
104
+ // app/+native-intent.ts
105
+ import { normalizeLink } from "@marktiderman/genesis-native";
106
+ import { links, linkHold } from "../lib/app"; // linkHold = createLinkHold()
107
+
108
+ export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
109
+ const to = normalizeLink(path, links); // unknown paths → "/"
110
+ if (to && to !== "/") linkHold.hold(to);
111
+ return to;
112
+ }
113
+ ```
114
+
115
+ `links` is `{ schemes: ["myapp"], hosts: ["example.com"], map: { "/c/:id": "/clients/:id" } }`.
116
+ A path the map doesn't know, or one with a `.` or `..` segment, goes to
117
+ `"/"` and `onUnknown`; `ignore` patterns return `null` so the router
118
+ leaves them alone (an auth callback).
119
+
120
+ ## Slots
121
+
122
+ `slots` replaces a part, `slotProps` feeds it, `null` drops it.
123
+
124
+ | Slot | Default | Props |
125
+ | --- | --- | --- |
126
+ | `boot` | `BootScreen` (a plain loading view; the splash covers it at launch) | `{ status }` |
127
+ | `fatal` | `FatalScreen` (`PlainFatalScreen` if the slot throws) | `{ error, retry }` |
128
+ | `errorBoundary` | `GenesisErrorBoundary` | `{ children, fallback, onError, resetKey, onRetry }` |
129
+ | `signedOut` | your screens | `{ children }` |
130
+ | `forceUpgrade` | `ForceUpgradeScreen` (with `updates`) | `{ minimumVersion, installedVersion, storeUrl, openStore, retry, checking }` |
131
+ | `maintenance` | `MaintenanceScreen` (with `updates`) | `{ message, retry, retrying }` |
132
+ | `updateBanner` | `UpdateBanner` (with `updates`) | `{ reload, dismiss, canReload, blocked, reloading }` |
133
+ | `whatsNew` | `WhatsNewSheet` (with `updates`) | `{ notes, version, dismiss }` |
134
+
135
+ The last four render only when the `updates` prop is given. Ejecting a
136
+ blocking one (`forceUpgrade: null`, `maintenance: null`) shows your screens
137
+ instead: a slot that is turned off never blocks.
138
+
139
+ ## Updates: force upgrade, maintenance, update banner, what's new
140
+
141
+ `@marktiderman/genesis-native/updates` is the updates block. Build it once
142
+ and pass it in; deleting the `updates={updates}` line turns it off.
143
+
144
+ ```ts
145
+ // lib/app.ts
146
+ import * as Updates from "expo-updates";
147
+ import * as Application from "expo-application";
148
+ import AsyncStorage from "@react-native-async-storage/async-storage";
149
+ import { AppState, Linking, Platform } from "react-native";
150
+ import { createUpdates, supabasePolicySource } from "@marktiderman/genesis-native/updates";
151
+
152
+ export const updates = createUpdates({
153
+ platform: Platform.OS,
154
+ application: Application,
155
+ updates: Updates,
156
+ policy: supabasePolicySource(supabase), // or staticPolicySource({ … }), or your own
157
+ storage: AsyncStorage,
158
+ appState: AppState,
159
+ openURL: Linking.openURL,
160
+ channels: { enabled: __DEV__ || process.env.EXPO_PUBLIC_PREVIEW === "1" },
161
+ });
162
+
163
+ // app/_layout.tsx
164
+ <GenesisNativeApp updates={updates} … />
165
+ ```
166
+
167
+ - **Force upgrade.** The policy names the oldest store version per platform
168
+ (`minimumVersion: { ios: "1.4.0" }`) and its store link (`storeUrl`). An
169
+ older install sees the `forceUpgrade` slot, blocking, with "Update" (the
170
+ store link, opened with `openURL`) and "Check again". A minimum without a
171
+ store link for the platform forces nothing: there would be no way out.
172
+ - **Blocking screens cover, they don't unmount.** The force-upgrade and
173
+ maintenance screens are drawn over your screens, which stay mounted (hidden
174
+ from touch and screen readers), so maintenance that starts and ends
175
+ mid-session keeps a half-filled form and the navigation state.
176
+ - **Maintenance.** `maintenance: { active: true, message }` shows the
177
+ `maintenance` slot, blocking, with "Try again" (fetches the policy again).
178
+ - **Update banner.** On launch and on coming to the front (at most every
179
+ `checkIntervalMs`, 15 minutes), expo-updates is asked for a newer update
180
+ for this binary's runtime version and it is downloaded; the `updateBanner`
181
+ slot then offers "Restart" and "Later". It **never reloads on its own**, and
182
+ never while anything is mid-edit: a form calls `useBlockReload(isDirty)`,
183
+ and `canReload` gives the app its own say (a throw counts as no). "Later"
184
+ loses nothing: a downloaded update also applies on the next cold start.
185
+ - **What's new.** Notes (`{ version, title?, body }`, from the policy and from
186
+ `notes`) for the versions after the one last seen, up to this one, once, in
187
+ the `whatsNew` slot. A fresh install shows nothing; the version seen is in
188
+ `storage`.
189
+ - **Version signature.** `useVersionSignature()` gives `short`
190
+ (`1.4.0 (37) · preview · 3f2a9c1b`) for a Settings "About" row and `text` (version,
191
+ build, platform, channel, runtime, update id, emergency launch) for a support
192
+ report. A value the modules can't give prints as `unknown`.
193
+ - **Channel switching** (test builds only): `useUpdates().channels.switchTo("pr-42")`
194
+ overrides the `expo-channel-name` header, fetches and reloads (or reports
195
+ `blocked` mid-edit). It throws unless `channels: { enabled: true }`; never
196
+ enable it in a store build. `channels.reset()` goes back and always works.
197
+
198
+ **Policy sources.** `UpdatePolicySource` is `{ load(): Promise<UpdatePolicy | null> }`.
199
+ `staticPolicySource(policy)` is a policy written in the app;
200
+ `supabasePolicySource(client, { appId })` reads `public.app_release_policy`
201
+ (migration `0002_genesis_app_release_policy.sql` in
202
+ `@marktiderman/genesis-supabase`, readable by anyone, written only with the
203
+ service role).
204
+
205
+ **It fails open.** A missing policy must never lock anyone out:
206
+
207
+ - The first load (the policy and the version whose notes were seen) waits
208
+ behind the splash for at most `policyTimeoutMs` (3 s), then carries on, so
209
+ a hung network or a stalled storage read never fails the boot. An answer
210
+ that arrives later still applies.
211
+ - No answer, an error, a malformed version (`1.2-beta`, `""`) or an unknown
212
+ installed version never blocks.
213
+ - A policy fetched from the network is cached. Offline, a cached minimum
214
+ version still forces the upgrade if the cache is at most `cacheMaxAgeMs`
215
+ (7 days) old by the phone's clock, and not if its time is in the future (the
216
+ clock moved back). A cached maintenance flag never blocks: maintenance
217
+ needs a live answer. After a live maintenance answer, a "Try again" that
218
+ can't reach the server keeps the screen up.
219
+
220
+ Each part on its own: `ForceUpgradeScreen`, `MaintenanceScreen`,
221
+ `UpdateBanner`, `WhatsNewSheet`; `createPolicyStore` + `evaluatePolicy`,
222
+ `createUpdateChecker` + `createReloadGuard`, `createWhatsNewStore`,
223
+ `versionSignature`, `createChannelSwitcher`, `compareVersions`; and the hooks
224
+ under `UpdatesProvider` (or the shell): `useForceUpgrade`, `useMaintenance`,
225
+ `useUpdateBanner`, `useWhatsNew`, `useVersionSignature`, `useBlockReload`,
226
+ `useCanReload`, `useUpdatePolicy`, `useUpdateCheck`.
227
+
228
+ `expo-updates` and `expo-application` are optional peers passed in, never
229
+ imported: without `updates` there is no banner and no channel switching;
230
+ without `application` nothing is forced and no notes show. The banner uses
231
+ `react-native-safe-area-context`, a peer (genesis-ui-native needs it too).
232
+
233
+ ## Each part on its own
234
+
235
+ `createBoot` + `BootProvider` + `useBoot`; `GenesisErrorBoundary` +
236
+ `installGlobalErrorHandler` + `installRejectionTracker` + `FatalScreen` +
237
+ `PlainFatalScreen`; `createNativeQueryClient` + `connectQueryLifecycle`, and
238
+ `createQueryPersistence` / `persistQueries` from `/persist`;
239
+ `createSessionStore` + `SessionProvider` + `useSession` + `AuthGate`;
240
+ `normalizeLink`; `createLinkHold` + `useHeldLink`.
241
+
242
+ `createHydrationGate()` keeps a persisted store's early writes off the disk
243
+ until it has loaded what was there:
244
+
245
+ ```ts
246
+ const gate = createHydrationGate();
247
+ const useStore = create(persist(init, {
248
+ name: "prefs",
249
+ storage: createJSONStorage(() => gate.storage(AsyncStorage)),
250
+ // Open only after a good read: after a failed one, writes would replace what's on disk.
251
+ onRehydrateStorage: () => (_state, error) => {
252
+ if (!error) gate.markHydrated();
253
+ },
254
+ }));
255
+ ```
256
+
257
+ ## Native modules are injected
258
+
259
+ The package imports no Expo or native module beyond React Native's own
260
+ `View`, `Text` and `Pressable` (for the screens that must not depend on anything). The
261
+ splash (`expo-splash-screen`), storage (AsyncStorage or MMKV), `ErrorUtils`
262
+ (the global by default), the rejection tracker (Hermes's by default),
263
+ `AppState` and NetInfo are passed in, so it runs in tests with fakes. Peers:
264
+ `react`, `react-native`, `@tanstack/react-query`, for `/persist` the two
265
+ TanStack persister packages, and for `/updates` `react-native-safe-area-context`
266
+ (with `expo-updates` and `expo-application` passed in).
267
+
268
+ @stability Beta