@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/errors.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors: a boundary per screen tree, and handlers for errors React never sees
|
|
3
|
+
* (a fatal global error from `ErrorUtils`, an unhandled promise rejection).
|
|
4
|
+
* All of them report through one `onError` (wire Sentry there) and show the
|
|
5
|
+
* fatal screen instead of letting the app die; its "Try again" remounts the
|
|
6
|
+
* tree with fresh state.
|
|
7
|
+
*/
|
|
8
|
+
import { Component, type ComponentType, type ErrorInfo, type ReactNode } from "react";
|
|
9
|
+
import { Pressable, Text, View } from "react-native";
|
|
10
|
+
import { NativeButton, NativeEmptyState } from "@marktiderman/genesis-ui-native/components";
|
|
11
|
+
import { toError } from "./boot";
|
|
12
|
+
|
|
13
|
+
export interface FatalScreenProps {
|
|
14
|
+
error: Error;
|
|
15
|
+
/** Clears the error and remounts the app below the shell (and re-runs a failed boot). */
|
|
16
|
+
retry: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The default fatal screen: what went wrong and "Try again". */
|
|
20
|
+
export function FatalScreen({ error, retry }: FatalScreenProps) {
|
|
21
|
+
return (
|
|
22
|
+
<NativeEmptyState
|
|
23
|
+
variant="error"
|
|
24
|
+
title="Something went wrong"
|
|
25
|
+
body="The app hit a problem. Try again, and if it keeps happening, close and reopen the app."
|
|
26
|
+
details={error.message}
|
|
27
|
+
primaryAction={
|
|
28
|
+
<NativeButton onPress={retry} testID="genesis-fatal-retry">
|
|
29
|
+
Try again
|
|
30
|
+
</NativeButton>
|
|
31
|
+
}
|
|
32
|
+
testID="genesis-fatal"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The last-resort fatal screen: React Native primitives only, no theme, no
|
|
39
|
+
* context, nothing that can itself fail. Shown when the providers could not
|
|
40
|
+
* start, or when the `fatal` slot threw.
|
|
41
|
+
*/
|
|
42
|
+
export function PlainFatalScreen({ error, retry }: FatalScreenProps) {
|
|
43
|
+
return (
|
|
44
|
+
<View testID="genesis-fatal-plain" style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 24, gap: 12 }}>
|
|
45
|
+
<Text accessibilityRole="header" style={{ fontSize: 17, fontWeight: "600" }}>
|
|
46
|
+
Something went wrong
|
|
47
|
+
</Text>
|
|
48
|
+
<Text style={{ textAlign: "center" }}>{error.message}</Text>
|
|
49
|
+
<Pressable accessibilityRole="button" onPress={retry} testID="genesis-fatal-plain-retry" style={{ padding: 12 }}>
|
|
50
|
+
<Text style={{ fontSize: 17 }}>Try again</Text>
|
|
51
|
+
</Pressable>
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ErrorReport {
|
|
57
|
+
/** True when the app could not go on (a render error, a failed boot, a fatal global error). */
|
|
58
|
+
fatal: boolean;
|
|
59
|
+
source: "boundary" | "global" | "boot" | "rejection";
|
|
60
|
+
componentStack?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type OnError = (error: Error, report: ErrorReport) => void;
|
|
64
|
+
|
|
65
|
+
export interface GenesisErrorBoundaryProps {
|
|
66
|
+
children?: ReactNode;
|
|
67
|
+
/** What to render instead of the tree that threw. */
|
|
68
|
+
fallback: ComponentType<FatalScreenProps>;
|
|
69
|
+
onError?: OnError;
|
|
70
|
+
/** Change it to clear a caught error (the tree remounts). */
|
|
71
|
+
resetKey?: unknown;
|
|
72
|
+
/** Called by the fallback's retry, after the error is cleared. */
|
|
73
|
+
onRetry?: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface BoundaryState {
|
|
77
|
+
error: Error | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An error boundary whose fallback gets `{ error, retry }`. */
|
|
81
|
+
export class GenesisErrorBoundary extends Component<GenesisErrorBoundaryProps, BoundaryState> {
|
|
82
|
+
override state: BoundaryState = { error: null };
|
|
83
|
+
|
|
84
|
+
static getDerivedStateFromError(error: unknown): BoundaryState {
|
|
85
|
+
return { error: toError(error) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override componentDidCatch(error: unknown, info: ErrorInfo) {
|
|
89
|
+
this.props.onError?.(toError(error), { fatal: true, source: "boundary", componentStack: info.componentStack ?? undefined });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override componentDidUpdate(previous: GenesisErrorBoundaryProps) {
|
|
93
|
+
if (this.state.error && previous.resetKey !== this.props.resetKey) this.setState({ error: null });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
retry = () => {
|
|
97
|
+
this.setState({ error: null });
|
|
98
|
+
this.props.onRetry?.();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
override render() {
|
|
102
|
+
const { error } = this.state;
|
|
103
|
+
if (!error) return this.props.children ?? null;
|
|
104
|
+
const Fallback = this.props.fallback;
|
|
105
|
+
return <Fallback error={error} retry={this.retry} />;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** React Native's `ErrorUtils` (a global in every RN app), or a stand-in. */
|
|
110
|
+
export interface ErrorUtilsLike {
|
|
111
|
+
getGlobalHandler(): ((error: unknown, isFatal?: boolean) => void) | undefined;
|
|
112
|
+
setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface GlobalErrorHandlerOptions {
|
|
116
|
+
errorUtils: ErrorUtilsLike;
|
|
117
|
+
onError?: OnError;
|
|
118
|
+
/** A fatal error: show the fatal screen. The previous handler (which would end the app) is not called. */
|
|
119
|
+
onFatal: (error: Error) => void;
|
|
120
|
+
/**
|
|
121
|
+
* True while a fatal screen is already up. Another fatal error then goes to
|
|
122
|
+
* the previous handler, which ends the app (it restarts clean) rather than
|
|
123
|
+
* leaving it stuck behind a screen that may itself be broken.
|
|
124
|
+
*/
|
|
125
|
+
alreadyFatal?: () => boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Route global errors to `onError`. A fatal one goes to `onFatal` instead of
|
|
130
|
+
* React Native's default handler, which would close the app; a non-fatal one
|
|
131
|
+
* still reaches the previous handler (LogBox in development). Returns a
|
|
132
|
+
* function that puts the previous handler back.
|
|
133
|
+
*/
|
|
134
|
+
export function installGlobalErrorHandler({ errorUtils, onError, onFatal, alreadyFatal }: GlobalErrorHandlerOptions): () => void {
|
|
135
|
+
const previous = errorUtils.getGlobalHandler();
|
|
136
|
+
errorUtils.setGlobalHandler((raw, isFatal) => {
|
|
137
|
+
const error = toError(raw);
|
|
138
|
+
try {
|
|
139
|
+
onError?.(error, { fatal: Boolean(isFatal), source: "global" });
|
|
140
|
+
} catch {
|
|
141
|
+
// A failing reporter must not take the handler down with it.
|
|
142
|
+
}
|
|
143
|
+
if (isFatal && !alreadyFatal?.()) onFatal(error);
|
|
144
|
+
else previous?.(raw, isFatal);
|
|
145
|
+
});
|
|
146
|
+
return () => {
|
|
147
|
+
// With no previous handler, leave one that only logs, never ours (it would call an unmounted app).
|
|
148
|
+
errorUtils.setGlobalHandler(previous ?? ((error) => console.error(error)));
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `globalThis.ErrorUtils` in a React Native runtime, undefined elsewhere. */
|
|
153
|
+
export function defaultErrorUtils(): ErrorUtilsLike | undefined {
|
|
154
|
+
const candidate = (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils;
|
|
155
|
+
return candidate && typeof candidate.setGlobalHandler === "function" ? candidate : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Hermes's `HermesInternal.enablePromiseRejectionTracker`, or a stand-in. */
|
|
159
|
+
export type EnableRejectionTracker = (options: {
|
|
160
|
+
allRejections: boolean;
|
|
161
|
+
onUnhandled: (id: number, rejection: unknown) => void;
|
|
162
|
+
onHandled: (id: number) => void;
|
|
163
|
+
}) => void;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hermes's rejection tracker, when the app runs on Hermes. React Native sends
|
|
167
|
+
* unhandled rejections to `ExceptionsManager` (LogBox in development), never
|
|
168
|
+
* to `ErrorUtils`' global handler, so without this they never reach `onError`.
|
|
169
|
+
*/
|
|
170
|
+
export function defaultRejectionTracker(): EnableRejectionTracker | undefined {
|
|
171
|
+
const hermes = (globalThis as { HermesInternal?: { enablePromiseRejectionTracker?: EnableRejectionTracker } }).HermesInternal;
|
|
172
|
+
const enable = hermes?.enablePromiseRejectionTracker;
|
|
173
|
+
return typeof enable === "function" ? enable.bind(hermes) : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Report unhandled promise rejections to `onError` (non-fatal). This replaces
|
|
178
|
+
* React Native's own tracker, so each rejection is also written with
|
|
179
|
+
* `console.warn`, which LogBox shows in development as before.
|
|
180
|
+
*/
|
|
181
|
+
export function installRejectionTracker({ enable, onError }: { enable: EnableRejectionTracker; onError: OnError }): void {
|
|
182
|
+
enable({
|
|
183
|
+
allRejections: true,
|
|
184
|
+
onUnhandled: (id, rejection) => {
|
|
185
|
+
const error = toError(rejection);
|
|
186
|
+
console.warn(`Uncaught (in promise, id: ${id}): ${error.message}`);
|
|
187
|
+
try {
|
|
188
|
+
onError(error, { fatal: false, source: "rejection" });
|
|
189
|
+
} catch {
|
|
190
|
+
// A failing reporter must not throw inside the promise machinery.
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
onHandled: () => {},
|
|
194
|
+
});
|
|
195
|
+
}
|
package/src/held-link.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold a deep link until sign-in completes.
|
|
3
|
+
*
|
|
4
|
+
* expo-router opens every incoming link itself. For a signed-in user that is
|
|
5
|
+
* all that should happen. For a signed-out user, the auth layout redirects to
|
|
6
|
+
* sign-in and the link is lost; holding it lets the app go there once the user
|
|
7
|
+
* has signed in. The hold lives outside React, because expo-router's
|
|
8
|
+
* `+native-intent` runs before any component:
|
|
9
|
+
*
|
|
10
|
+
* // lib/links.ts
|
|
11
|
+
* export const linkHold = createLinkHold();
|
|
12
|
+
* // app/+native-intent.ts
|
|
13
|
+
* export function redirectSystemPath({ path }) {
|
|
14
|
+
* const to = normalizeLink(path, links);
|
|
15
|
+
* if (to && to !== "/") linkHold.hold(to);
|
|
16
|
+
* return to;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* `useHeldLink` follows a held link only if the user was seen signed out
|
|
20
|
+
* while it was held. A link that arrives while signed in, or at a cold start
|
|
21
|
+
* that turns out signed in, is dropped, since the router already opened it.
|
|
22
|
+
* `GenesisNativeApp` runs it for you (`links.onRelease`).
|
|
23
|
+
*/
|
|
24
|
+
import { useEffect, useRef, useSyncExternalStore } from "react";
|
|
25
|
+
|
|
26
|
+
export interface LinkHold {
|
|
27
|
+
/** Hold `path`, replacing any held one. */
|
|
28
|
+
hold(path: string): void;
|
|
29
|
+
peek(): string | null;
|
|
30
|
+
/** Return the held path and forget it. */
|
|
31
|
+
take(): string | null;
|
|
32
|
+
clear(): void;
|
|
33
|
+
subscribe(listener: () => void): () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A place to hold one deep link until sign-in completes; lives outside React. */
|
|
37
|
+
export function createLinkHold(): LinkHold {
|
|
38
|
+
let held: string | null = null;
|
|
39
|
+
const listeners = new Set<() => void>();
|
|
40
|
+
const set = (next: string | null) => {
|
|
41
|
+
if (held === next) return;
|
|
42
|
+
held = next;
|
|
43
|
+
for (const listener of listeners) listener();
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
hold: (path) => set(path),
|
|
47
|
+
peek: () => held,
|
|
48
|
+
take() {
|
|
49
|
+
const path = held;
|
|
50
|
+
set(null);
|
|
51
|
+
return path;
|
|
52
|
+
},
|
|
53
|
+
clear: () => set(null),
|
|
54
|
+
subscribe(listener) {
|
|
55
|
+
listeners.add(listener);
|
|
56
|
+
return () => listeners.delete(listener);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `"pending"` while booting or reading the session. */
|
|
62
|
+
export type HeldLinkAuth = "pending" | "signedOut" | "signedIn";
|
|
63
|
+
|
|
64
|
+
export interface UseHeldLinkOptions {
|
|
65
|
+
auth: HeldLinkAuth;
|
|
66
|
+
/** Go there, e.g. `(path) => router.replace(path)`. Called once per link held across a sign-in. */
|
|
67
|
+
onRelease: (path: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Follow the held link after a sign-in, drop it otherwise; returns the link still held. */
|
|
71
|
+
export function useHeldLink(hold: LinkHold, { auth, onRelease }: UseHeldLinkOptions): string | null {
|
|
72
|
+
const held = useSyncExternalStore(hold.subscribe, hold.peek, hold.peek);
|
|
73
|
+
const release = useRef(onRelease);
|
|
74
|
+
release.current = onRelease;
|
|
75
|
+
// Was the user signed out while this link was held?
|
|
76
|
+
const waitedForSignIn = useRef(false);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (held === null) {
|
|
79
|
+
waitedForSignIn.current = false;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (auth === "signedOut") waitedForSignIn.current = true;
|
|
83
|
+
if (auth !== "signedIn") return;
|
|
84
|
+
const path = hold.take();
|
|
85
|
+
const follow = waitedForSignIn.current;
|
|
86
|
+
waitedForSignIn.current = false;
|
|
87
|
+
if (path !== null && follow) release.current(path);
|
|
88
|
+
}, [auth, held, hold]);
|
|
89
|
+
return held;
|
|
90
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hydration gate holds writes until a persisted store has loaded from disk.
|
|
3
|
+
*
|
|
4
|
+
* The bug it prevents: a persisted store (zustand `persist`, say) is created
|
|
5
|
+
* with defaults, something sets a value before the saved state has been read,
|
|
6
|
+
* and that write lands on disk first, replacing what the user had. With the
|
|
7
|
+
* gate, a write made before hydration never reaches disk (`storage()`), or
|
|
8
|
+
* waits for it (`run()`).
|
|
9
|
+
*
|
|
10
|
+
* const gate = createHydrationGate();
|
|
11
|
+
* persist(store, {
|
|
12
|
+
* storage: createJSONStorage(() => gate.storage(AsyncStorage)),
|
|
13
|
+
* // Open only after a good read: after a failed one, writes would replace what's on disk.
|
|
14
|
+
* onRehydrateStorage: () => (_state, error) => {
|
|
15
|
+
* if (!error) gate.markHydrated();
|
|
16
|
+
* },
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
import type { AsyncStorageLike } from "./query";
|
|
20
|
+
|
|
21
|
+
export interface HydrationGate {
|
|
22
|
+
readonly hydrated: boolean;
|
|
23
|
+
/** Call once the store has read its saved state. Idempotent. */
|
|
24
|
+
markHydrated(): void;
|
|
25
|
+
/** Resolves once hydrated. */
|
|
26
|
+
whenHydrated(): Promise<void>;
|
|
27
|
+
/** Run a write now if hydrated, otherwise once hydrated. */
|
|
28
|
+
run<T>(write: () => T | Promise<T>): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* The storage, with writes made before hydration dropped (reads pass
|
|
31
|
+
* through, since hydration is a read). After hydration the store writes
|
|
32
|
+
* its merged state on its next change, so nothing is lost but the stale
|
|
33
|
+
* write. (A zustand migration's own write during hydration is dropped too;
|
|
34
|
+
* the old version stays on disk and is migrated again until then.)
|
|
35
|
+
*/
|
|
36
|
+
storage<S extends AsyncStorageLike>(storage: S): S;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HydrationGateOptions {
|
|
40
|
+
/** Told about each write `storage()` dropped (to log it in development). */
|
|
41
|
+
onDroppedWrite?: (key: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A gate that holds writes until a persisted store has hydrated. */
|
|
45
|
+
export function createHydrationGate({ onDroppedWrite }: HydrationGateOptions = {}): HydrationGate {
|
|
46
|
+
let hydrated = false;
|
|
47
|
+
let open!: () => void;
|
|
48
|
+
const opened = new Promise<void>((resolve) => {
|
|
49
|
+
open = resolve;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const gate: HydrationGate = {
|
|
53
|
+
get hydrated() {
|
|
54
|
+
return hydrated;
|
|
55
|
+
},
|
|
56
|
+
markHydrated() {
|
|
57
|
+
if (hydrated) return;
|
|
58
|
+
hydrated = true;
|
|
59
|
+
open();
|
|
60
|
+
},
|
|
61
|
+
whenHydrated: () => opened,
|
|
62
|
+
async run(write) {
|
|
63
|
+
if (!hydrated) await opened;
|
|
64
|
+
return write();
|
|
65
|
+
},
|
|
66
|
+
storage(storage) {
|
|
67
|
+
const setItem = async (key: string, value: string) => {
|
|
68
|
+
if (!hydrated) return onDroppedWrite?.(key);
|
|
69
|
+
return storage.setItem(key, value);
|
|
70
|
+
};
|
|
71
|
+
const removeItem = async (key: string) => {
|
|
72
|
+
if (!hydrated) return onDroppedWrite?.(key);
|
|
73
|
+
return storage.removeItem(key);
|
|
74
|
+
};
|
|
75
|
+
// A proxy, not a spread: a storage class's prototype methods (and its
|
|
76
|
+
// private fields, through `this`) keep working.
|
|
77
|
+
return new Proxy(storage, {
|
|
78
|
+
get(target, property) {
|
|
79
|
+
if (property === "setItem") return setItem;
|
|
80
|
+
if (property === "removeItem") return removeItem;
|
|
81
|
+
const value = Reflect.get(target, property, target) as unknown;
|
|
82
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return gate;
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @marktiderman/genesis-native — the native app shell.
|
|
3
|
+
*
|
|
4
|
+
* Rung 1: `<GenesisNativeApp>`. Rung 2: its `slots` / `slotProps`.
|
|
5
|
+
* Rungs 3 and 4: each part below on its own, as a primitive and a hook.
|
|
6
|
+
* Query persistence is in `@marktiderman/genesis-native/persist`.
|
|
7
|
+
*
|
|
8
|
+
* @stability Beta
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
GenesisNativeApp,
|
|
12
|
+
type GenesisNativeAppProps,
|
|
13
|
+
type NativeAppSlots,
|
|
14
|
+
type NativeQueryClientOptions,
|
|
15
|
+
BootScreen,
|
|
16
|
+
type SplashModule,
|
|
17
|
+
type BootScreenProps,
|
|
18
|
+
type SignedOutProps,
|
|
19
|
+
} from "./app";
|
|
20
|
+
export {
|
|
21
|
+
createBoot,
|
|
22
|
+
BootProvider,
|
|
23
|
+
useBoot,
|
|
24
|
+
BootTimeoutError,
|
|
25
|
+
type BootController,
|
|
26
|
+
type BootOptions,
|
|
27
|
+
type BootState,
|
|
28
|
+
type BootStatus,
|
|
29
|
+
type BootStep,
|
|
30
|
+
type BootStepContext,
|
|
31
|
+
type UseBootReturn,
|
|
32
|
+
} from "./boot";
|
|
33
|
+
export {
|
|
34
|
+
FatalScreen,
|
|
35
|
+
PlainFatalScreen,
|
|
36
|
+
GenesisErrorBoundary,
|
|
37
|
+
installGlobalErrorHandler,
|
|
38
|
+
installRejectionTracker,
|
|
39
|
+
defaultErrorUtils,
|
|
40
|
+
defaultRejectionTracker,
|
|
41
|
+
type EnableRejectionTracker,
|
|
42
|
+
type ErrorReport,
|
|
43
|
+
type ErrorUtilsLike,
|
|
44
|
+
type FatalScreenProps,
|
|
45
|
+
type GenesisErrorBoundaryProps,
|
|
46
|
+
type GlobalErrorHandlerOptions,
|
|
47
|
+
type OnError,
|
|
48
|
+
} from "./errors";
|
|
49
|
+
export {
|
|
50
|
+
createNativeQueryClient,
|
|
51
|
+
connectQueryLifecycle,
|
|
52
|
+
DAY_MS,
|
|
53
|
+
type AppStateLike,
|
|
54
|
+
type AsyncStorageLike,
|
|
55
|
+
type NetInfoLike,
|
|
56
|
+
type QueryLifecycleOptions,
|
|
57
|
+
type QueryPersistence,
|
|
58
|
+
} from "./query";
|
|
59
|
+
export { createHydrationGate, type HydrationGate, type HydrationGateOptions } from "./hydration-gate";
|
|
60
|
+
export {
|
|
61
|
+
AuthGate,
|
|
62
|
+
SessionProvider,
|
|
63
|
+
createSessionStore,
|
|
64
|
+
useSession,
|
|
65
|
+
type AuthClientLike,
|
|
66
|
+
type AuthGateProps,
|
|
67
|
+
type SessionState,
|
|
68
|
+
type SessionStatus,
|
|
69
|
+
type SessionStore,
|
|
70
|
+
type SessionStoreOptions,
|
|
71
|
+
} from "./auth";
|
|
72
|
+
export { normalizeLink, type LinkOptions, type LinkTarget } from "./links";
|
|
73
|
+
export { createLinkHold, useHeldLink, type HeldLinkAuth, type LinkHold, type UseHeldLinkOptions } from "./held-link";
|
|
74
|
+
export {
|
|
75
|
+
useBlocks,
|
|
76
|
+
useBlockSettings,
|
|
77
|
+
mergeBlockSettings,
|
|
78
|
+
type BlockSettingsSection,
|
|
79
|
+
type NativeBlock,
|
|
80
|
+
type NativeBlockRootProps,
|
|
81
|
+
type SettingsRowLike,
|
|
82
|
+
type UseBlockSettingsOptions,
|
|
83
|
+
} from "./blocks";
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links: turn an incoming universal link or app link into an app path.
|
|
3
|
+
*
|
|
4
|
+
* Meant for expo-router's `app/+native-intent.ts`:
|
|
5
|
+
*
|
|
6
|
+
* export function redirectSystemPath({ path }: { path: string }) {
|
|
7
|
+
* return normalizeLink(path, links);
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* A link to a path the map doesn't know goes to `"/"` (and `onUnknown`), so a
|
|
11
|
+
* stale or mistyped link opens the app instead of a "not found" screen. It
|
|
12
|
+
* never throws: anything it can't read is unknown.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A target path (`"/clients/:id"`, params filled in) or a function of the params. */
|
|
16
|
+
export type LinkTarget = string | ((params: Record<string, string>) => string);
|
|
17
|
+
|
|
18
|
+
export interface LinkOptions {
|
|
19
|
+
/** App schemes, without `://` (`["myapp"]`). `myapp://clients/1` is the path `/clients/1`. */
|
|
20
|
+
schemes?: readonly string[];
|
|
21
|
+
/** Hosts of universal / app links (`["example.com"]`). Other hosts are unknown. */
|
|
22
|
+
hosts?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Incoming path pattern → app path. A segment `:name` captures one
|
|
25
|
+
* segment, a final `*` captures the rest (as `params["*"]`). The first
|
|
26
|
+
* match wins; `"/"` is always known.
|
|
27
|
+
*/
|
|
28
|
+
map: Readonly<Record<string, LinkTarget>>;
|
|
29
|
+
/** Patterns to ignore (return `null`, so expo-router doesn't navigate), e.g. an auth callback. */
|
|
30
|
+
ignore?: readonly string[];
|
|
31
|
+
/** Told about each link that went to `"/"` because nothing matched. */
|
|
32
|
+
onUnknown?: (url: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const URL_PATTERN = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/i;
|
|
36
|
+
|
|
37
|
+
interface Parsed {
|
|
38
|
+
path: string;
|
|
39
|
+
query: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parse(url: string, schemes: readonly string[], hosts: readonly string[]): Parsed | null {
|
|
43
|
+
const trimmed = url.trim();
|
|
44
|
+
const match = URL_PATTERN.exec(trimmed);
|
|
45
|
+
if (!match) {
|
|
46
|
+
// Already a path (expo-router passes paths as well as URLs).
|
|
47
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
|
|
48
|
+
const noHash = trimmed.split("#")[0] ?? "";
|
|
49
|
+
const at = noHash.indexOf("?");
|
|
50
|
+
return at < 0 ? { path: noHash, query: "" } : { path: noHash.slice(0, at), query: noHash.slice(at) };
|
|
51
|
+
}
|
|
52
|
+
const [, scheme = "", host = "", path = "", query = ""] = match;
|
|
53
|
+
const lowerScheme = scheme.toLowerCase();
|
|
54
|
+
if (lowerScheme === "http" || lowerScheme === "https") {
|
|
55
|
+
const bareHost = host.toLowerCase().replace(/:\d+$/, "");
|
|
56
|
+
if (!hosts.some((h) => h.toLowerCase() === bareHost)) return null;
|
|
57
|
+
return { path, query };
|
|
58
|
+
}
|
|
59
|
+
if (!schemes.some((s) => s.toLowerCase() === lowerScheme)) return null;
|
|
60
|
+
// In `myapp://clients/1`, "clients" sits where a host would.
|
|
61
|
+
return { path: `/${host}${path}`, query };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clean(path: string): string {
|
|
65
|
+
const joined = `/${path}`.replace(/\/{2,}/g, "/");
|
|
66
|
+
return joined.length > 1 ? joined.replace(/\/$/, "") : joined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchPattern(pattern: string, path: string): Record<string, string> | null {
|
|
70
|
+
const want = clean(pattern).split("/").slice(1);
|
|
71
|
+
const have = path.split("/").slice(1);
|
|
72
|
+
const params: Record<string, string> = {};
|
|
73
|
+
for (let i = 0; i < want.length; i++) {
|
|
74
|
+
const segment = want[i]!;
|
|
75
|
+
if (segment === "*" && i === want.length - 1) {
|
|
76
|
+
params["*"] = have.slice(i).join("/");
|
|
77
|
+
return params;
|
|
78
|
+
}
|
|
79
|
+
const value = have[i];
|
|
80
|
+
if (value === undefined) return null;
|
|
81
|
+
if (segment.startsWith(":")) params[segment.slice(1)] = value;
|
|
82
|
+
else if (segment !== value) return null;
|
|
83
|
+
}
|
|
84
|
+
return want.length === have.length ? params : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fill(target: LinkTarget, params: Record<string, string>): string {
|
|
88
|
+
if (typeof target === "function") return target(params);
|
|
89
|
+
return target.replace(/:([A-Za-z_][A-Za-z0-9_]*)|\*$/g, (token, name: string | undefined) =>
|
|
90
|
+
name === undefined ? (params["*"] ?? "") : (params[name] ?? token),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isDotSegment(segment: string): boolean {
|
|
95
|
+
let decoded = segment;
|
|
96
|
+
try {
|
|
97
|
+
decoded = decodeURIComponent(segment);
|
|
98
|
+
} catch {
|
|
99
|
+
// Malformed escapes stay as they are.
|
|
100
|
+
}
|
|
101
|
+
return decoded === "." || decoded === "..";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function withQuery(path: string, query: string): string {
|
|
105
|
+
if (!query || query === "?") return path;
|
|
106
|
+
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The app path for `url`, `"/"` when it is unknown, or `null` when it is ignored. */
|
|
110
|
+
export function normalizeLink(url: string, options: LinkOptions): string | null {
|
|
111
|
+
const { schemes = [], hosts = [], map, ignore = [], onUnknown } = options;
|
|
112
|
+
const unknown = () => {
|
|
113
|
+
onUnknown?.(url);
|
|
114
|
+
return "/";
|
|
115
|
+
};
|
|
116
|
+
let parsed: Parsed | null;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parse(url, schemes, hosts);
|
|
119
|
+
} catch {
|
|
120
|
+
parsed = null;
|
|
121
|
+
}
|
|
122
|
+
if (!parsed) return unknown();
|
|
123
|
+
const path = clean(parsed.path);
|
|
124
|
+
// "." and ".." would let a link climb out of the path it matched (a `*` capture included).
|
|
125
|
+
if (path.split("/").some(isDotSegment)) {
|
|
126
|
+
return unknown();
|
|
127
|
+
}
|
|
128
|
+
if (ignore.some((pattern) => matchPattern(pattern, path))) return null;
|
|
129
|
+
if (path === "/") return withQuery("/", parsed.query);
|
|
130
|
+
for (const [pattern, target] of Object.entries(map)) {
|
|
131
|
+
const params = matchPattern(pattern, path);
|
|
132
|
+
if (!params) continue;
|
|
133
|
+
try {
|
|
134
|
+
return withQuery(fill(target, params), parsed.query);
|
|
135
|
+
} catch {
|
|
136
|
+
return unknown();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return unknown();
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// NativeWind className augmentation for react-native types.
|
|
2
|
+
// This adds className to RN component props so ui-native components
|
|
3
|
+
// typecheck correctly both standalone and when consumed by apps.
|
|
4
|
+
// The top-level import makes this a module augmentation (not an ambient declaration),
|
|
5
|
+
// so it merges with the real react-native types rather than replacing them.
|
|
6
|
+
|
|
7
|
+
// This import is required to make this file a module augmentation (not an ambient declaration).
|
|
8
|
+
// Removing it silently changes the semantics from "merge with" to "replace" the react-native types.
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
10
|
+
import type { AnimatedProps } from "react-native";
|
|
11
|
+
|
|
12
|
+
declare module "react-native" {
|
|
13
|
+
interface ViewProps {
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
interface TextProps {
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
interface TextInputProps {
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
interface ImageProps {
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
interface ImagePropsBase {
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
interface PressableProps {
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
interface SwitchProps {
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
interface ScrollViewProps {
|
|
35
|
+
className?: string;
|
|
36
|
+
contentContainerClassName?: string;
|
|
37
|
+
}
|
|
38
|
+
interface TouchableWithoutFeedbackProps {
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
}
|