@marktiderman/genesis-native 0.1.0 → 0.3.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/README.md +354 -10
- package/dist/block-C5-noMhB.d.ts +223 -0
- package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push.d.ts +169 -0
- package/dist/push.js +996 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/package.json +222 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/push/backend.ts +129 -0
- package/src/push/block.tsx +545 -0
- package/src/push/index.ts +102 -0
- package/src/push/modules.ts +111 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +189 -0
- package/src/push/routing.ts +27 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- package/dist/chunk-MMTPWUWI.js.map +0 -1
package/src/app.tsx
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<GenesisNativeApp>`: everything above an Expo app's screens, so the root
|
|
3
|
+
* `_layout.tsx` is a few lines.
|
|
4
|
+
*
|
|
5
|
+
* last-resort boundary (React Native primitives only)
|
|
6
|
+
* └ theme (GenesisThemeProvider, owning the platform color scheme)
|
|
7
|
+
* └ keyboard (`keyboardProvider`, once, then `keyboardScroll` for form screens)
|
|
8
|
+
* └ toasts (`NativeToastProvider`, so `useToast()` works on every screen; `toast={null}` drops it)
|
|
9
|
+
* └ data (GenesisProvider over the Supabase client, the QueryClient)
|
|
10
|
+
* └ session + boot (splash held until both are known)
|
|
11
|
+
* └ blocks' roots (with `blocks`, e.g. updates: force upgrade, maintenance, banner, what's new)
|
|
12
|
+
* └ errorBoundary → `bottomSheet`'s provider, if given → your screens (or `signedOut`)
|
|
13
|
+
*
|
|
14
|
+
* Every error the shell catches goes to each block's `onError` (the
|
|
15
|
+
* observability block reports it) and then to the app's own `onError`.
|
|
16
|
+
*
|
|
17
|
+
* `blocks` (`defineBlock` declarations a native shell can run) are resolved
|
|
18
|
+
* once: requirements checked, boot steps run behind the splash, roots
|
|
19
|
+
* mounted, slot fills put under your `slots`, Settings rows handed to
|
|
20
|
+
* `useBlockSettings`.
|
|
21
|
+
* Global errors, render errors and a failed boot show the `fatal` slot with
|
|
22
|
+
* "Try again". Every part is a slot (`slots` / `slotProps`, `null` drops it)
|
|
23
|
+
* and is also exported on its own.
|
|
24
|
+
*/
|
|
25
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
|
|
26
|
+
import { Text, View } from "react-native";
|
|
27
|
+
import type { QueryClient, QueryClientConfig } from "@tanstack/react-query";
|
|
28
|
+
import { GenesisThemeProvider } from "@marktiderman/genesis-design-system/theme";
|
|
29
|
+
import type { CanonicalBrand } from "@marktiderman/genesis-design-system";
|
|
30
|
+
import { GenesisProvider, createSupabaseProvider, type DataProvider, type SupabaseClientInput } from "@marktiderman/genesis-core/provider";
|
|
31
|
+
import { renderSlot, type SlotPropsFor, type Slots } from "@marktiderman/genesis-core/slots";
|
|
32
|
+
import { KeyboardScrollProvider, type KeyboardScrollProps, type KeyboardToolbarProps } from "@marktiderman/genesis-ui-native/layouts";
|
|
33
|
+
import {
|
|
34
|
+
GenesisModulesProvider,
|
|
35
|
+
NativeSheetProvider,
|
|
36
|
+
NativeToastProvider,
|
|
37
|
+
type GenesisModules,
|
|
38
|
+
type NativeSheetLibrary,
|
|
39
|
+
type NativeToastProviderProps,
|
|
40
|
+
} from "@marktiderman/genesis-ui-native/components";
|
|
41
|
+
import { BootProvider, createBoot, type BootOptions, type BootStatus, type BootStep } from "./boot";
|
|
42
|
+
import {
|
|
43
|
+
FatalScreen,
|
|
44
|
+
GenesisErrorBoundary,
|
|
45
|
+
PlainFatalScreen,
|
|
46
|
+
defaultErrorUtils,
|
|
47
|
+
defaultRejectionTracker,
|
|
48
|
+
installGlobalErrorHandler,
|
|
49
|
+
installRejectionTracker,
|
|
50
|
+
type EnableRejectionTracker,
|
|
51
|
+
type ErrorUtilsLike,
|
|
52
|
+
type FatalScreenProps,
|
|
53
|
+
type GenesisErrorBoundaryProps,
|
|
54
|
+
type OnError,
|
|
55
|
+
} from "./errors";
|
|
56
|
+
import { SessionProvider, createSessionStore, type AuthClientLike } from "./auth";
|
|
57
|
+
import { useHeldLink, type HeldLinkAuth, type LinkHold } from "./held-link";
|
|
58
|
+
import { connectQueryLifecycle, createNativeQueryClient, type QueryLifecycleOptions, type QueryPersistence } from "./query";
|
|
59
|
+
import { resolveBlocks } from "@marktiderman/genesis-features/blocks";
|
|
60
|
+
import { BlocksProvider, reportToBlocks, type NativeBlock } from "./blocks";
|
|
61
|
+
import type { DevtoolsSlots } from "./devtools/slots";
|
|
62
|
+
// Types only: the block's code is in the `/updates` entry and arrives with the `updates` prop.
|
|
63
|
+
import type { UpdatesBlock, UpdatesSlots } from "./updates/block";
|
|
64
|
+
// Types only, likewise: the push block (`/push`) arrives in `blocks`.
|
|
65
|
+
import type { PushSlots } from "./push/block";
|
|
66
|
+
|
|
67
|
+
/** `expo-splash-screen`, or a stand-in. Call `preventAutoHideAsync()` at module scope yourself too. */
|
|
68
|
+
export interface SplashModule {
|
|
69
|
+
preventAutoHideAsync?: () => Promise<unknown>;
|
|
70
|
+
hideAsync: () => Promise<unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BootScreenProps {
|
|
74
|
+
status: BootStatus;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SignedOutProps {
|
|
78
|
+
/** Your screens. The default renders them, for an expo-router layout that redirects. */
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A type, not an interface, so it fits the slots contract's Record constraint.
|
|
83
|
+
// The updates block's slots (`forceUpgrade`, `maintenance`, `updateBanner`,
|
|
84
|
+
// `whatsNew`) render only when the `updates` prop is given.
|
|
85
|
+
export type NativeAppSlots = UpdatesSlots &
|
|
86
|
+
PushSlots &
|
|
87
|
+
DevtoolsSlots & {
|
|
88
|
+
/** Shown while booting: under the splash at launch, on its own after "Try again". Default `BootScreen`. */
|
|
89
|
+
boot: BootScreenProps;
|
|
90
|
+
/** A failed boot, a fatal global error or a render error. Default `FatalScreen`. */
|
|
91
|
+
fatal: FatalScreenProps;
|
|
92
|
+
/** The boundary around your screens. Default `GenesisErrorBoundary`. */
|
|
93
|
+
errorBoundary: GenesisErrorBoundaryProps;
|
|
94
|
+
/** What a signed-out user sees. Default your screens (gate them with `useSession`). */
|
|
95
|
+
signedOut: SignedOutProps;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export interface NativeQueryClientOptions {
|
|
99
|
+
/** Your own client. Default `createNativeQueryClient(config)`. */
|
|
100
|
+
client?: QueryClient;
|
|
101
|
+
config?: QueryClientConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface GenesisNativeAppProps {
|
|
105
|
+
brand: CanonicalBrand;
|
|
106
|
+
/** A Supabase client (from `createGenesisSupabase` in `@marktiderman/genesis-supabase`). */
|
|
107
|
+
supabase: AuthClientLike & SupabaseClientInput;
|
|
108
|
+
/** The data provider. Default `createSupabaseProvider(supabase)`. */
|
|
109
|
+
provider?: DataProvider;
|
|
110
|
+
queryClient?: NativeQueryClientOptions;
|
|
111
|
+
/**
|
|
112
|
+
* Save queries to the phone: `persistQueries({ storage, keys, buster })`
|
|
113
|
+
* from `@marktiderman/genesis-native/persist`. The saved cache belongs to
|
|
114
|
+
* one user: it is restored only for that user and cleared when the user
|
|
115
|
+
* changes (sign-out, a session that ended while the app was closed, an
|
|
116
|
+
* account switch).
|
|
117
|
+
*/
|
|
118
|
+
persist?: (client: QueryClient) => QueryPersistence;
|
|
119
|
+
/** `expo-splash-screen`: hidden once boot has finished and the session is known. */
|
|
120
|
+
splash?: SplashModule;
|
|
121
|
+
/**
|
|
122
|
+
* `KeyboardProvider` from react-native-keyboard-controller, mounted once
|
|
123
|
+
* around the app. Its keyboard views (`keyboardScroll` included) do nothing
|
|
124
|
+
* without it: give both or neither. Read once: it wraps the whole app, so
|
|
125
|
+
* a different component later would remount everything below it. To pass
|
|
126
|
+
* it props, define the wrapper at module scope, not inline.
|
|
127
|
+
*/
|
|
128
|
+
keyboardProvider?: ComponentType<{ children: ReactNode }>;
|
|
129
|
+
/** `KeyboardAwareScrollView` from react-native-keyboard-controller, for form screens. Needs `keyboardProvider`. */
|
|
130
|
+
keyboardScroll?: ComponentType<KeyboardScrollProps>;
|
|
131
|
+
/**
|
|
132
|
+
* `KeyboardToolbar` from react-native-keyboard-controller: previous / next /
|
|
133
|
+
* done above the keyboard on Genesis's form layouts. Needs `keyboardScroll`
|
|
134
|
+
* (and so `keyboardProvider`); without them it is ignored.
|
|
135
|
+
*/
|
|
136
|
+
keyboardToolbar?: ComponentType<KeyboardToolbarProps>;
|
|
137
|
+
/**
|
|
138
|
+
* The optional native modules the app installed, for Genesis components to
|
|
139
|
+
* use (`GenesisModulesProvider` from `@marktiderman/genesis-ui-native`):
|
|
140
|
+
* FlashList, expo-image, Reanimated, expo-glass-effect, expo-symbols, a
|
|
141
|
+
* lucide icon set, the @expo/ui date picker. Each one left out falls back
|
|
142
|
+
* to plain React Native. Read once.
|
|
143
|
+
*/
|
|
144
|
+
modules?: GenesisModules;
|
|
145
|
+
/**
|
|
146
|
+
* `@gorhom/bottom-sheet` v5, to opt every `NativeSheet` into it (snap
|
|
147
|
+
* points, drag between heights): `import * as BottomSheet from "@gorhom/bottom-sheet"`,
|
|
148
|
+
* then `bottomSheet={BottomSheet}`. The app mounts `NativeSheetProvider`
|
|
149
|
+
* (and so the library's `BottomSheetModalProvider`) for you; the app's root
|
|
150
|
+
* must still be inside gesture-handler's `GestureHandlerRootView`, with
|
|
151
|
+
* Reanimated set up. Leave it out and sheets are React Native's own modal
|
|
152
|
+
* (an iOS page sheet), which needs neither. Read once.
|
|
153
|
+
*/
|
|
154
|
+
bottomSheet?: NativeSheetLibrary;
|
|
155
|
+
/**
|
|
156
|
+
* Toasts (`useToast()` from `@marktiderman/genesis-ui-native`): `NativeToastProvider` is
|
|
157
|
+
* mounted around the screens, at the top of the screen by default (a bottom toast would sit on
|
|
158
|
+
* a tab bar). Pass its options to place it, or `null` to mount your own. Read once.
|
|
159
|
+
*/
|
|
160
|
+
toast?: Pick<NativeToastProviderProps, "position" | "disableAnimation"> | null;
|
|
161
|
+
/** Follow a link held while signed out, once the user signs in. */
|
|
162
|
+
links?: { hold: LinkHold; onRelease: (path: string) => void };
|
|
163
|
+
/** Run before first render, in order (wipe-on-reinstall, hydrate stores, fonts). Read once. */
|
|
164
|
+
steps?: readonly BootStep[];
|
|
165
|
+
bootOptions?: BootOptions;
|
|
166
|
+
/** Longest wait for Supabase's `getSession()` before using the stored session (offline). Default 5000 ms. */
|
|
167
|
+
sessionWaitMs?: number;
|
|
168
|
+
/** Default `globalThis.ErrorUtils`. */
|
|
169
|
+
errorUtils?: ErrorUtilsLike;
|
|
170
|
+
/** Report unhandled promise rejections to `onError`. Default Hermes's tracker; `null` turns it off. */
|
|
171
|
+
rejectionTracker?: EnableRejectionTracker | null;
|
|
172
|
+
/** `AppState` and NetInfo, so queries refetch on focus and know the network. */
|
|
173
|
+
lifecycle?: QueryLifecycleOptions;
|
|
174
|
+
/**
|
|
175
|
+
* Every caught error, after the blocks' own `onError` (the observability
|
|
176
|
+
* block, `createObservability` from `/observability`, reports to Sentry
|
|
177
|
+
* there). A throwing reporter is ignored.
|
|
178
|
+
*/
|
|
179
|
+
onError?: OnError;
|
|
180
|
+
/** After a signed-in user signs out, once the query cache is cleared. */
|
|
181
|
+
onSignedOut?: () => void;
|
|
182
|
+
/**
|
|
183
|
+
* The updates block: `createUpdates(…)` from `@marktiderman/genesis-native/updates`
|
|
184
|
+
* (force upgrade, maintenance, the update banner, what's new). Its policy
|
|
185
|
+
* loads behind the splash for at most `policyTimeoutMs` and never fails the
|
|
186
|
+
* boot. Read once; leave it out to turn the block off.
|
|
187
|
+
*/
|
|
188
|
+
updates?: UpdatesBlock;
|
|
189
|
+
/**
|
|
190
|
+
* Blocks to run (`createUpdates(…)` is one). Resolved once at start: a
|
|
191
|
+
* block whose requirement is missing, or two blocks contributing the same
|
|
192
|
+
* slot, nav entry or Settings row, shows the fatal screen and reports a
|
|
193
|
+
* `BlockError` to `onError`. Read once.
|
|
194
|
+
*/
|
|
195
|
+
blocks?: readonly NativeBlock[];
|
|
196
|
+
/**
|
|
197
|
+
* Your features (FeatureIds) that blocks may require but that aren't
|
|
198
|
+
* blocks. A block requiring anything that is neither one of `blocks` nor
|
|
199
|
+
* listed here is a `BlockError`. Read once.
|
|
200
|
+
*/
|
|
201
|
+
appFeatures?: readonly string[];
|
|
202
|
+
slots?: Slots<NativeAppSlots>;
|
|
203
|
+
slotProps?: SlotPropsFor<NativeAppSlots>;
|
|
204
|
+
children?: ReactNode;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The default boot slot: a plain loading view (the splash covers it at launch). */
|
|
208
|
+
export function BootScreen(_props: BootScreenProps) {
|
|
209
|
+
return (
|
|
210
|
+
<View testID="genesis-boot" accessibilityRole="progressbar" style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
|
211
|
+
<Text>Loading…</Text>
|
|
212
|
+
</View>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const PassThrough: ComponentType<SignedOutProps> = ({ children }) => <>{children}</>;
|
|
217
|
+
|
|
218
|
+
// The web provider's types don't list the native-only prop; Metro resolves the native provider.
|
|
219
|
+
const ThemeProvider = GenesisThemeProvider as ComponentType<{
|
|
220
|
+
brand: CanonicalBrand;
|
|
221
|
+
syncNativeAppearance?: boolean;
|
|
222
|
+
children: ReactNode;
|
|
223
|
+
}>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The root of an Expo app: theme, keyboard, data, boot behind the splash, errors, session and held links.
|
|
227
|
+
*
|
|
228
|
+
* @stability Beta
|
|
229
|
+
*/
|
|
230
|
+
export function GenesisNativeApp(props: GenesisNativeAppProps) {
|
|
231
|
+
// Above every provider: if one of them (or the shell's own set-up) throws,
|
|
232
|
+
// this shows a screen made of nothing that can fail, and retry starts over.
|
|
233
|
+
const [rootKey, setRootKey] = useState(0);
|
|
234
|
+
const onErrorRef = useRef(props.onError);
|
|
235
|
+
onErrorRef.current = props.onError;
|
|
236
|
+
// The blocks as given, before they are resolved: an error here may come from resolving them.
|
|
237
|
+
const blocksRef = useRef(givenBlocks(props));
|
|
238
|
+
const splashRef = useRef(props.splash);
|
|
239
|
+
splashRef.current = props.splash;
|
|
240
|
+
return (
|
|
241
|
+
<GenesisErrorBoundary
|
|
242
|
+
key={rootKey}
|
|
243
|
+
fallback={PlainFatalScreen}
|
|
244
|
+
onError={(e, r) => {
|
|
245
|
+
reportToBlocks(blocksRef.current, e, r);
|
|
246
|
+
try {
|
|
247
|
+
onErrorRef.current?.(e, r);
|
|
248
|
+
} catch {
|
|
249
|
+
// A failing reporter must not stop the fatal screen.
|
|
250
|
+
}
|
|
251
|
+
// The shell (which hides the splash) never mounted: hide it here, or the screen stays behind it.
|
|
252
|
+
splashRef.current?.hideAsync().catch(() => {});
|
|
253
|
+
}}
|
|
254
|
+
onRetry={() => setRootKey((key) => key + 1)}
|
|
255
|
+
>
|
|
256
|
+
<ThemedShell {...props} />
|
|
257
|
+
</GenesisErrorBoundary>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
declare const __DEV__: boolean | undefined;
|
|
262
|
+
|
|
263
|
+
const MISSING_KEYBOARD_PROVIDER =
|
|
264
|
+
"GenesisNativeApp: `keyboardScroll` was given without `keyboardProvider`, so form screens won't move above the keyboard. " +
|
|
265
|
+
'Pass `keyboardProvider={KeyboardProvider}` from "react-native-keyboard-controller" too.';
|
|
266
|
+
|
|
267
|
+
const TOOLBAR_WITHOUT_SCROLL =
|
|
268
|
+
"GenesisNativeApp: `keyboardToolbar` was given without `keyboardScroll`, so it is ignored. " +
|
|
269
|
+
'Pass `keyboardScroll={KeyboardAwareScrollView}` and `keyboardProvider={KeyboardProvider}` from "react-native-keyboard-controller" too.';
|
|
270
|
+
|
|
271
|
+
function ThemedShell(props: GenesisNativeAppProps) {
|
|
272
|
+
const { brand, keyboardScroll, keyboardToolbar } = props;
|
|
273
|
+
// Frozen: a new component type here (an inline wrapper) would remount the shell and reset the app.
|
|
274
|
+
const [Keyboard] = useState(() => props.keyboardProvider);
|
|
275
|
+
// Frozen too: an inline object would hand every component a new module set each render.
|
|
276
|
+
const [modules] = useState(() => props.modules);
|
|
277
|
+
const [toast] = useState(() => (props.toast === null ? null : { position: "top" as const, ...props.toast }));
|
|
278
|
+
const missingProvider = Boolean(keyboardScroll) && !Keyboard;
|
|
279
|
+
const toolbarWithoutScroll = Boolean(keyboardToolbar) && !keyboardScroll;
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
282
|
+
if (missingProvider) console.error(MISSING_KEYBOARD_PROVIDER);
|
|
283
|
+
if (toolbarWithoutScroll) console.error(TOOLBAR_WITHOUT_SCROLL);
|
|
284
|
+
}, [missingProvider, toolbarWithoutScroll]);
|
|
285
|
+
let body = <Shell {...props} />;
|
|
286
|
+
// Inside the modules: a toast animates with Reanimated and draws Liquid Glass when the app gave them.
|
|
287
|
+
if (toast) body = <NativeToastProvider {...toast}>{body}</NativeToastProvider>;
|
|
288
|
+
if (modules) body = <GenesisModulesProvider modules={modules}>{body}</GenesisModulesProvider>;
|
|
289
|
+
if (keyboardScroll) body = <KeyboardScrollProvider component={keyboardScroll} toolbar={keyboardToolbar}>{body}</KeyboardScrollProvider>;
|
|
290
|
+
if (Keyboard) body = <Keyboard>{body}</Keyboard>;
|
|
291
|
+
return (
|
|
292
|
+
<ThemeProvider brand={brand} syncNativeAppearance>
|
|
293
|
+
{body}
|
|
294
|
+
</ThemeProvider>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const SESSION_STEP_MARGIN_MS = 15_000;
|
|
299
|
+
|
|
300
|
+
function Shell(props: GenesisNativeAppProps) {
|
|
301
|
+
const { supabase, provider, queryClient: queryOptions, persist, splash, links, steps = [], bootOptions, sessionWaitMs = 5000 } = props;
|
|
302
|
+
const { errorUtils, rejectionTracker, lifecycle, slotProps, children } = props;
|
|
303
|
+
const latest = useRef(props);
|
|
304
|
+
latest.current = props;
|
|
305
|
+
const [blockList] = useState(() => givenBlocks(props));
|
|
306
|
+
// Read once, like the keyboard provider: a different module later would remount the screens.
|
|
307
|
+
const [bottomSheet] = useState(() => props.bottomSheet);
|
|
308
|
+
const onError: OnError = (error, report) => {
|
|
309
|
+
reportToBlocks(blockList, error, report);
|
|
310
|
+
try {
|
|
311
|
+
latest.current.onError?.(error, report);
|
|
312
|
+
} catch {
|
|
313
|
+
// A failing reporter must not take the shell down with it.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
// How many fatal screens are up (the global handler lets a second fatal error end the app).
|
|
317
|
+
const fatalShown = useRef(0);
|
|
318
|
+
|
|
319
|
+
// Created once: the shell's parts live as long as the app.
|
|
320
|
+
const [parts] = useState(() => {
|
|
321
|
+
const list = blockList;
|
|
322
|
+
const blocks = resolveBlocks(list, props.appFeatures ? [...list.map((b) => b.id), ...props.appFeatures] : undefined, {
|
|
323
|
+
platform: "native",
|
|
324
|
+
slots: NATIVE_APP_SLOT_NAMES,
|
|
325
|
+
});
|
|
326
|
+
const client = queryOptions?.client ?? createNativeQueryClient(queryOptions?.config);
|
|
327
|
+
const persistence = persist ? persist(client) : null;
|
|
328
|
+
const session = createSessionStore(supabase, {
|
|
329
|
+
waitMs: sessionWaitMs,
|
|
330
|
+
// Another user (or nobody) now: nothing of the last user's may show or stay on disk.
|
|
331
|
+
onUserChange: (previous, next) => {
|
|
332
|
+
client.clear();
|
|
333
|
+
void persistence?.clear();
|
|
334
|
+
if (previous !== null && next === null) latest.current.onSignedOut?.();
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const boot = createBoot(
|
|
338
|
+
[
|
|
339
|
+
...steps,
|
|
340
|
+
// Blocks' steps, in dependency order (the updates block's settles within its own timeout and never throws).
|
|
341
|
+
...blocks.blocks.flatMap((block) => block.bootSteps ?? []),
|
|
342
|
+
{ id: "genesis:session", run: () => session.load(), timeoutMs: sessionWaitMs + SESSION_STEP_MARGIN_MS },
|
|
343
|
+
// After the session, so only the cache saved for this user is restored.
|
|
344
|
+
...(persistence ? [{ id: "genesis:query-cache", run: () => persistence.restore(session.getState().userId ?? "") }] : []),
|
|
345
|
+
],
|
|
346
|
+
bootOptions,
|
|
347
|
+
);
|
|
348
|
+
// One component type for the life of the app, so a re-render never remounts a visible fatal screen.
|
|
349
|
+
function FatalFallback(fallbackProps: FatalScreenProps) {
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
fatalShown.current++;
|
|
352
|
+
return () => {
|
|
353
|
+
fatalShown.current--;
|
|
354
|
+
};
|
|
355
|
+
}, []);
|
|
356
|
+
const { slotProps: sp } = latest.current;
|
|
357
|
+
const s = withBlockSlots(blocks.slots, latest.current.slots);
|
|
358
|
+
return (
|
|
359
|
+
// A `fatal` slot that throws falls back to the plain screen.
|
|
360
|
+
<GenesisErrorBoundary fallback={PlainFatalScreen} onError={onError} onRetry={fallbackProps.retry}>
|
|
361
|
+
{renderSlot(FatalScreen, s?.fatal, fallbackProps, sp?.fatal)}
|
|
362
|
+
</GenesisErrorBoundary>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return { client, persistence, session, boot, FatalFallback, blocks, provider: provider ?? createSupabaseProvider(supabase) };
|
|
366
|
+
});
|
|
367
|
+
const { boot, session, persistence, client, FatalFallback } = parts;
|
|
368
|
+
const slots = withBlockSlots(parts.blocks.slots, props.slots);
|
|
369
|
+
const bootState = useSyncExternalStore(boot.subscribe, boot.getState, boot.getState);
|
|
370
|
+
const sessionState = useSyncExternalStore(session.subscribe, session.getState, session.getState);
|
|
371
|
+
const [fatal, setFatal] = useState<Error | null>(null);
|
|
372
|
+
const [resetKey, setResetKey] = useState(0);
|
|
373
|
+
|
|
374
|
+
useEffect(() => session.start(), [session]);
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
void boot.start();
|
|
377
|
+
}, [boot]);
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (bootState.status === "failed" && bootState.error) onError(bootState.error, { fatal: true, source: "boot" });
|
|
380
|
+
}, [bootState.status, bootState.error]);
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (bootState.status === "ready" && persistence) return persistence.subscribe(sessionState.userId ?? "");
|
|
383
|
+
}, [bootState.status, persistence, sessionState.userId]);
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const utils = errorUtils ?? defaultErrorUtils();
|
|
386
|
+
if (!utils) return;
|
|
387
|
+
return installGlobalErrorHandler({ errorUtils: utils, onError, onFatal: setFatal, alreadyFatal: () => fatalShown.current > 0 });
|
|
388
|
+
}, [errorUtils]);
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
const enable = rejectionTracker === null ? undefined : (rejectionTracker ?? defaultRejectionTracker());
|
|
391
|
+
if (enable) installRejectionTracker({ enable, onError });
|
|
392
|
+
}, [rejectionTracker]);
|
|
393
|
+
useEffect(() => (lifecycle ? connectQueryLifecycle(lifecycle) : undefined), [lifecycle]);
|
|
394
|
+
|
|
395
|
+
// Splash: up until boot has finished (the session step included), or failed.
|
|
396
|
+
const settled = bootState.status === "ready" || bootState.status === "failed" || fatal !== null;
|
|
397
|
+
const splashHidden = useRef(false);
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!settled || !splash || splashHidden.current) return;
|
|
400
|
+
splashHidden.current = true;
|
|
401
|
+
splash.hideAsync().catch(() => {});
|
|
402
|
+
}, [settled, splash]);
|
|
403
|
+
|
|
404
|
+
const auth: HeldLinkAuth = bootState.status !== "ready" || sessionState.status === "loading" ? "pending" : sessionState.status;
|
|
405
|
+
useHeldLink(links?.hold ?? NO_HOLD, { auth: links ? auth : "pending", onRelease: (path) => links?.onRelease(path) });
|
|
406
|
+
|
|
407
|
+
const retry = () => {
|
|
408
|
+
setFatal(null);
|
|
409
|
+
setResetKey((key) => key + 1);
|
|
410
|
+
if (boot.getState().status === "failed") void boot.retry();
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
let content: ReactNode;
|
|
414
|
+
if (fatal) {
|
|
415
|
+
content = <FatalFallback error={fatal} retry={retry} />;
|
|
416
|
+
} else if (bootState.status === "failed" && bootState.error) {
|
|
417
|
+
content = <FatalFallback error={bootState.error} retry={retry} />;
|
|
418
|
+
} else if (bootState.status !== "ready") {
|
|
419
|
+
content = renderSlot(BootScreen, slots?.boot, { status: bootState.status }, slotProps?.boot);
|
|
420
|
+
} else {
|
|
421
|
+
const screens =
|
|
422
|
+
sessionState.status === "signedOut" ? renderSlot(PassThrough, slots?.signedOut, { children }, slotProps?.signedOut) : children;
|
|
423
|
+
const guarded = renderSlot(
|
|
424
|
+
GenesisErrorBoundary,
|
|
425
|
+
slots?.errorBoundary,
|
|
426
|
+
{ fallback: FatalFallback, onError, resetKey, onRetry: retry, children: screens },
|
|
427
|
+
slotProps?.errorBoundary,
|
|
428
|
+
);
|
|
429
|
+
// The first block's root is the outermost.
|
|
430
|
+
content = parts.blocks.blocks.reduceRight<ReactNode>((inner, block) => {
|
|
431
|
+
const Root = block.Root;
|
|
432
|
+
return Root ? (
|
|
433
|
+
<Root key={block.id} slots={slots} slotProps={slotProps}>
|
|
434
|
+
{inner}
|
|
435
|
+
</Root>
|
|
436
|
+
) : (
|
|
437
|
+
inner
|
|
438
|
+
);
|
|
439
|
+
}, guarded);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<GenesisProvider provider={parts.provider} queryClient={client}>
|
|
444
|
+
<SessionProvider store={session}>
|
|
445
|
+
<BootProvider boot={boot}>
|
|
446
|
+
<BlocksProvider value={parts.blocks}>
|
|
447
|
+
<GenesisErrorBoundary key={resetKey} fallback={FatalFallback} onError={onError} onRetry={retry}>
|
|
448
|
+
{/* Sheet content renders in the library's portal, here: below data, session, boot and blocks. */}
|
|
449
|
+
{bottomSheet ? <NativeSheetProvider bottomSheet={bottomSheet}>{content}</NativeSheetProvider> : content}
|
|
450
|
+
</GenesisErrorBoundary>
|
|
451
|
+
</BlocksProvider>
|
|
452
|
+
</BootProvider>
|
|
453
|
+
</SessionProvider>
|
|
454
|
+
</GenesisProvider>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Every slot name, checked against NativeAppSlots by the compiler: a block filling any other is a BlockError.
|
|
459
|
+
const SLOT_NAMES: Record<keyof NativeAppSlots, true> = {
|
|
460
|
+
boot: true,
|
|
461
|
+
fatal: true,
|
|
462
|
+
errorBoundary: true,
|
|
463
|
+
signedOut: true,
|
|
464
|
+
forceUpgrade: true,
|
|
465
|
+
maintenance: true,
|
|
466
|
+
updateBanner: true,
|
|
467
|
+
whatsNew: true,
|
|
468
|
+
devtoolsFeedback: true,
|
|
469
|
+
pushPrompt: true,
|
|
470
|
+
};
|
|
471
|
+
const NATIVE_APP_SLOT_NAMES: readonly string[] = Object.keys(SLOT_NAMES);
|
|
472
|
+
|
|
473
|
+
/** Blocks' slot fills under the app's own slots: the app's (a component or `null`) always win. */
|
|
474
|
+
function withBlockSlots(fills: Readonly<Record<string, unknown>>, own: Slots<NativeAppSlots> | undefined): Slots<NativeAppSlots> | undefined {
|
|
475
|
+
if (Object.keys(fills).length === 0) return own;
|
|
476
|
+
return { ...(fills as Slots<NativeAppSlots>), ...own };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** `blocks`, with `updates` added when it was given on its own (given both ways, it runs once). */
|
|
480
|
+
function givenBlocks(props: Pick<GenesisNativeAppProps, "blocks" | "updates">): readonly NativeBlock[] {
|
|
481
|
+
const given = props.blocks ?? [];
|
|
482
|
+
return props.updates && !given.includes(props.updates) ? [...given, props.updates] : given;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const NO_HOLD: LinkHold = {
|
|
486
|
+
hold: () => {},
|
|
487
|
+
peek: () => null,
|
|
488
|
+
take: () => null,
|
|
489
|
+
clear: () => {},
|
|
490
|
+
subscribe: () => () => {},
|
|
491
|
+
};
|
package/src/auth.tsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth gate: the Supabase session as app state.
|
|
3
|
+
*
|
|
4
|
+
* `createSessionStore(supabase)` reads the session once (`load`, a boot step,
|
|
5
|
+
* so the splash stays up until it is known) and follows `onAuthStateChange`.
|
|
6
|
+
* `useSession()` reads it; `AuthGate` renders `signedIn` or `signedOut`.
|
|
7
|
+
*
|
|
8
|
+
* Offline, `getSession()` can wait about 30 seconds on a token refresh that
|
|
9
|
+
* can't succeed and then fail with a retryable error, although the session is
|
|
10
|
+
* still on the phone. So `load` waits at most `waitMs` (5 s) and, on a timeout
|
|
11
|
+
* or a network error, uses the stored session: the user stays signed in and
|
|
12
|
+
* sees their cached data, and Supabase's own events correct it once online.
|
|
13
|
+
*
|
|
14
|
+
* With expo-router, gate in a layout instead and let the router render:
|
|
15
|
+
*
|
|
16
|
+
* const { status } = useSession();
|
|
17
|
+
* if (status === "signedOut") return <Redirect href="/sign-in" />;
|
|
18
|
+
* return <Stack />;
|
|
19
|
+
*/
|
|
20
|
+
import { createContext, useContext, useSyncExternalStore, type ReactNode } from "react";
|
|
21
|
+
|
|
22
|
+
/** The parts of a Supabase client the gate uses; a `SupabaseClient` fits. */
|
|
23
|
+
export interface AuthClientLike<S = unknown> {
|
|
24
|
+
auth: {
|
|
25
|
+
getSession(): Promise<{ data: { session: S | null }; error?: unknown }>;
|
|
26
|
+
onAuthStateChange(callback: (event: string, session: S | null) => void): {
|
|
27
|
+
data: { subscription: { unsubscribe(): void } };
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SessionStatus = "loading" | "signedIn" | "signedOut";
|
|
33
|
+
|
|
34
|
+
export interface SessionState<S = unknown> {
|
|
35
|
+
status: SessionStatus;
|
|
36
|
+
session: S | null;
|
|
37
|
+
/** The signed-in user's id, or null. */
|
|
38
|
+
userId: string | null;
|
|
39
|
+
/** True when the session came from the phone because Supabase couldn't be reached. */
|
|
40
|
+
offline: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SessionStore<S = unknown> {
|
|
44
|
+
getState(): SessionState<S>;
|
|
45
|
+
subscribe(listener: () => void): () => void;
|
|
46
|
+
/** Read the current session (the boot step). Throws only on a non-network error. */
|
|
47
|
+
load(): Promise<void>;
|
|
48
|
+
/** Follow sign-in and sign-out; returns the unsubscribe. */
|
|
49
|
+
start(): () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SessionStoreOptions<S = unknown> {
|
|
53
|
+
/**
|
|
54
|
+
* The signed-in user changed after the session was first known: signed
|
|
55
|
+
* out, signed in, or switched account. `previous` and `next` are user ids
|
|
56
|
+
* (null when nobody). Clear caches here.
|
|
57
|
+
*/
|
|
58
|
+
onUserChange?: (previous: string | null, next: string | null) => void;
|
|
59
|
+
/** Longest wait for `getSession()` before using the stored session. Default 5000 ms. */
|
|
60
|
+
waitMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Read the session saved on the phone without refreshing it. Default: from
|
|
63
|
+
* the Supabase client's own storage (`auth.storage` under `auth.storageKey`).
|
|
64
|
+
*/
|
|
65
|
+
readStoredSession?: () => Promise<S | null>;
|
|
66
|
+
/** The user id in a session. Default `session.user.id`. */
|
|
67
|
+
userIdOf?: (session: S) => string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_WAIT_MS = 5000;
|
|
71
|
+
|
|
72
|
+
function defaultUserId(session: unknown): string | null {
|
|
73
|
+
const id = (session as { user?: { id?: unknown } } | null)?.user?.id;
|
|
74
|
+
return typeof id === "string" ? id : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A network failure (supabase-js `AuthRetryableFetchError`, status 0), as opposed to a real refusal. */
|
|
78
|
+
function isNetworkError(error: unknown): boolean {
|
|
79
|
+
const e = error as { name?: unknown; status?: unknown } | null;
|
|
80
|
+
return e?.name === "AuthRetryableFetchError" || e?.status === 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The session supabase-js keeps under `auth.storageKey`, read without a refresh. */
|
|
84
|
+
function storedSessionReader<S>(client: AuthClientLike<S>): () => Promise<S | null> {
|
|
85
|
+
return async () => {
|
|
86
|
+
const auth = client.auth as { storage?: { getItem(key: string): unknown }; storageKey?: unknown };
|
|
87
|
+
if (!auth.storage || typeof auth.storageKey !== "string") return null;
|
|
88
|
+
const raw = await auth.storage.getItem(auth.storageKey);
|
|
89
|
+
if (typeof raw !== "string") return (raw as S | null) ?? null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw) as S;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The Supabase session as a store: `load` reads it once, `start` follows auth events. */
|
|
99
|
+
export function createSessionStore<S = unknown>(client: AuthClientLike<S>, options: SessionStoreOptions<S> = {}): SessionStore<S> {
|
|
100
|
+
const { onUserChange, waitMs = DEFAULT_WAIT_MS, userIdOf = defaultUserId } = options;
|
|
101
|
+
const readStored = options.readStoredSession ?? storedSessionReader(client);
|
|
102
|
+
const listeners = new Set<() => void>();
|
|
103
|
+
let state: SessionState<S> = { status: "loading", session: null, userId: null, offline: false };
|
|
104
|
+
// Bumped by every auth event, so a slow `getSession()` can't overwrite a newer one.
|
|
105
|
+
let version = 0;
|
|
106
|
+
|
|
107
|
+
const set = (session: S | null, offline = false) => {
|
|
108
|
+
const userId = session ? userIdOf(session) : null;
|
|
109
|
+
const previous = state;
|
|
110
|
+
if (previous.status !== "loading" && previous.session === session && previous.offline === offline) return;
|
|
111
|
+
state = { status: session ? "signedIn" : "signedOut", session, userId, offline };
|
|
112
|
+
for (const listener of listeners) listener();
|
|
113
|
+
if (previous.status !== "loading" && previous.userId !== userId) onUserChange?.(previous.userId, userId);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
getState: () => state,
|
|
118
|
+
subscribe(listener) {
|
|
119
|
+
listeners.add(listener);
|
|
120
|
+
return () => listeners.delete(listener);
|
|
121
|
+
},
|
|
122
|
+
async load() {
|
|
123
|
+
const startedAt = version;
|
|
124
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
const timedOut = new Promise<"timeout">((resolve) => {
|
|
126
|
+
timer = setTimeout(() => resolve("timeout"), waitMs);
|
|
127
|
+
});
|
|
128
|
+
const result = await Promise.race([client.auth.getSession(), timedOut]).finally(() => clearTimeout(timer));
|
|
129
|
+
if (version !== startedAt) return; // an auth event already told us
|
|
130
|
+
if (result !== "timeout" && !result.error) return set(result.data.session);
|
|
131
|
+
if (result !== "timeout" && !isNetworkError(result.error)) {
|
|
132
|
+
const error = result.error;
|
|
133
|
+
throw error instanceof Error ? error : new Error("Could not read the session.", { cause: error });
|
|
134
|
+
}
|
|
135
|
+
// Offline (or Supabase is slow): carry on with what the phone has.
|
|
136
|
+
const stored = await readStored().catch(() => null);
|
|
137
|
+
if (version !== startedAt) return;
|
|
138
|
+
set(stored, stored !== null);
|
|
139
|
+
},
|
|
140
|
+
start() {
|
|
141
|
+
const { data } = client.auth.onAuthStateChange((_event, session) => {
|
|
142
|
+
version++;
|
|
143
|
+
set(session);
|
|
144
|
+
});
|
|
145
|
+
return () => data.subscription.unsubscribe();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const SessionContext = createContext<SessionStore | null>(null);
|
|
151
|
+
|
|
152
|
+
/** Makes a session store readable with `useSession()`. */
|
|
153
|
+
export function SessionProvider({ store, children }: { store: SessionStore; children: ReactNode }) {
|
|
154
|
+
return <SessionContext.Provider value={store}>{children}</SessionContext.Provider>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The session and whether it is known yet. Inside `GenesisNativeApp` or a `SessionProvider`. */
|
|
158
|
+
export function useSession<S = unknown>(): SessionState<S> {
|
|
159
|
+
const store = useContext(SessionContext);
|
|
160
|
+
if (!store) throw new Error("useSession must be used inside <GenesisNativeApp> or <SessionProvider>.");
|
|
161
|
+
return useSyncExternalStore(store.subscribe, store.getState, store.getState) as SessionState<S>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface AuthGateProps {
|
|
165
|
+
signedIn: ReactNode;
|
|
166
|
+
signedOut: ReactNode;
|
|
167
|
+
/** While the session is still being read. Default nothing (the splash is up). */
|
|
168
|
+
loading?: ReactNode;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Render one tree for a signed-in user and another for everyone else. */
|
|
172
|
+
export function AuthGate({ signedIn, signedOut, loading = null }: AuthGateProps) {
|
|
173
|
+
const { status } = useSession();
|
|
174
|
+
if (status === "loading") return <>{loading}</>;
|
|
175
|
+
return <>{status === "signedIn" ? signedIn : signedOut}</>;
|
|
176
|
+
}
|