@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 +21 -0
- package/README.md +267 -2
- package/dist/block-Dh5ny06W.d.ts +535 -0
- package/dist/chunk-MMTPWUWI.js +36 -0
- package/dist/chunk-MMTPWUWI.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.js +600 -0
- package/dist/index.js.map +1 -0
- package/dist/persist.d.ts +44 -0
- package/dist/persist.js +42 -0
- package/dist/persist.js.map +1 -0
- package/dist/query-DhH3lLFa.d.ts +65 -0
- package/dist/updates.d.ts +20 -0
- package/dist/updates.js +813 -0
- package/dist/updates.js.map +1 -0
- package/package.json +84 -3
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
|
-
#
|
|
1
|
+
# @marktiderman/genesis-native
|
|
2
2
|
|
|
3
|
-
|
|
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
|