@marktiderman/genesis-native 0.2.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.
Files changed (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
package/src/blocks.tsx ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Blocks on native: `defineBlock` declarations (from
3
+ * `@marktiderman/genesis-features/blocks`) with the two things only a native
4
+ * shell can run, boot steps behind the splash and a root around the screens.
5
+ * `<GenesisNativeApp blocks={[…]}>` resolves them once, runs their steps,
6
+ * mounts their roots, puts their slot fills under the app's own `slots`, and
7
+ * hands their Settings rows to `useBlockSettings`.
8
+ */
9
+ import { createContext, useContext, type ComponentType, type ReactNode } from "react";
10
+ import type { SlotPropsFor, Slots } from "@marktiderman/genesis-core/slots";
11
+ import type { BlockDef, BlockSettingsRow, Contributed, ResolvedBlocks } from "@marktiderman/genesis-features/blocks";
12
+ import type { BootStep } from "./boot";
13
+ import type { ErrorReport } from "./errors";
14
+ import type { NativeAppSlots } from "./app";
15
+
16
+ /** What a block's `Root` is given: the app's slots, with every block's fills merged under them. @stability Beta */
17
+ export interface NativeBlockRootProps {
18
+ slots?: Slots<NativeAppSlots>;
19
+ slotProps?: SlotPropsFor<NativeAppSlots>;
20
+ children?: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * A block a native shell can run. `contributes.slots` fills `GenesisNativeApp`
25
+ * slots by name (the app's own `slots` win).
26
+ *
27
+ * @stability Beta
28
+ */
29
+ export interface NativeBlock<Id extends string = string> extends BlockDef<Id, ComponentType<never>> {
30
+ /** Run behind the splash after the app's own `steps`, before the session. A step that fails fails the boot, so keep them bounded. */
31
+ readonly bootSteps?: readonly BootStep[];
32
+ /** Mounted around the screens, inside the shell's error boundary. The first block's root is the outermost. */
33
+ readonly Root?: ComponentType<NativeBlockRootProps>;
34
+ /**
35
+ * Every error the shell catches (render, global, boot, rejection), before
36
+ * the app's own `onError`. The observability block reports them here. A
37
+ * throw is ignored.
38
+ */
39
+ readonly onError?: (error: Error, report: ErrorReport) => void;
40
+ }
41
+
42
+ /** @internal Hand an error to each block's `onError`; one that throws never stops the rest. */
43
+ export function reportToBlocks(blocks: readonly NativeBlock[], error: Error, report: ErrorReport): void {
44
+ for (const block of blocks) {
45
+ if (!block.onError) continue;
46
+ try {
47
+ block.onError(error, report);
48
+ } catch {
49
+ // A failing reporter must not take the error path down with it.
50
+ }
51
+ }
52
+ }
53
+
54
+ const BlocksContext = createContext<ResolvedBlocks<NativeBlock> | null>(null);
55
+
56
+ /** @internal Provided by GenesisNativeApp. */
57
+ export const BlocksProvider = BlocksContext.Provider;
58
+
59
+ /**
60
+ * The resolved blocks: their ids, nav entries, Settings rows, routes and the
61
+ * rest, each tagged with its FeatureId. Throws outside `GenesisNativeApp`.
62
+ *
63
+ * @stability Beta
64
+ */
65
+ export function useBlocks(): ResolvedBlocks<NativeBlock> {
66
+ const blocks = useContext(BlocksContext);
67
+ if (!blocks) throw new Error("useBlocks: no blocks. Render inside <GenesisNativeApp>.");
68
+ return blocks;
69
+ }
70
+
71
+ /** A Settings section as `SettingsPage` from `@marktiderman/genesis-ui-native` takes it. @stability Beta */
72
+ export interface BlockSettingsSection<R extends { id: string; title: string } = SettingsRowLike> {
73
+ id: string;
74
+ title?: string;
75
+ footnote?: string;
76
+ rows: R[];
77
+ }
78
+
79
+ /** The row fields a block can fill. @stability Beta */
80
+ export interface SettingsRowLike {
81
+ id: string;
82
+ title: string;
83
+ subtitle?: string;
84
+ showChevron?: boolean;
85
+ onPress?: () => void;
86
+ testID?: string;
87
+ }
88
+
89
+ /** @stability Beta */
90
+ export interface UseBlockSettingsOptions {
91
+ /** Open a row's `href` (expo-router's `router.push`). Without it, rows with an `href` aren't pressable. */
92
+ navigate?: (href: string) => void;
93
+ /** Titles for sections a block names that `sections` doesn't have. Default the section id. */
94
+ sectionTitles?: Readonly<Record<string, string>>;
95
+ }
96
+
97
+ /**
98
+ * The app's Settings sections with every block's rows added: each row goes
99
+ * at the end of the section its `section` names (by `order`, then block
100
+ * order), and a section no one has yet is added after the rest.
101
+ *
102
+ * Calls each row's `useSubtitle` hook, so call it unconditionally at the top
103
+ * of the Settings screen. The blocks are fixed for the life of the app, so
104
+ * the hooks run in the same order on every render.
105
+ *
106
+ * @stability Beta
107
+ */
108
+ export function useBlockSettings<R extends SettingsRowLike>(
109
+ sections: readonly BlockSettingsSection<R>[],
110
+ options: UseBlockSettingsOptions = {},
111
+ ): BlockSettingsSection<R | SettingsRowLike>[] {
112
+ const blocks = useContext(BlocksContext);
113
+ const rows = blocks?.settings ?? [];
114
+ // The same hooks in the same order every render: `rows` never changes once the shell has resolved it.
115
+ const subtitles = rows.map((row) => row.useSubtitle?.());
116
+ return mergeBlockSettings(sections, rows, subtitles, options);
117
+ }
118
+
119
+ /** The merge `useBlockSettings` does, without hooks (subtitles already read). @stability Beta */
120
+ export function mergeBlockSettings<R extends SettingsRowLike>(
121
+ sections: readonly BlockSettingsSection<R>[],
122
+ rows: readonly Contributed<BlockSettingsRow>[],
123
+ subtitles: readonly (string | null | undefined)[] = [],
124
+ options: UseBlockSettingsOptions = {},
125
+ ): BlockSettingsSection<R | SettingsRowLike>[] {
126
+ const out: BlockSettingsSection<R | SettingsRowLike>[] = sections.map((s) => ({ ...s, rows: [...s.rows] }));
127
+ rows.forEach((row, i) => {
128
+ let section = out.find((s) => s.id === row.section);
129
+ if (!section) {
130
+ section = { id: row.section, title: options.sectionTitles?.[row.section] ?? row.section, rows: [] };
131
+ out.push(section);
132
+ }
133
+ const { navigate } = options;
134
+ const href = row.href;
135
+ section.rows.push({
136
+ id: row.id,
137
+ title: row.title,
138
+ subtitle: subtitles[i] ?? row.subtitle,
139
+ testID: row.testID,
140
+ ...(href && navigate ? { showChevron: true, onPress: () => navigate(href) } : {}),
141
+ });
142
+ });
143
+ return out;
144
+ }
package/src/boot.tsx ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Boot: the async work an app must finish before its first screen (wipe a
3
+ * session left by a reinstall, hydrate stores, load fonts, restore the query
4
+ * cache, read the session).
5
+ *
6
+ * Steps run once each, in order. A step that finished is never run again, so
7
+ * "try again" after a failure resumes at the step that failed. Each step has a
8
+ * timeout (10 seconds by default), so a hung step ends in the fatal screen with
9
+ * retry instead of trapping the splash. A minimum keeps a fast boot from
10
+ * flashing the splash.
11
+ *
12
+ * `createBoot` is the controller (no React); `BootProvider` + `useBoot()` read
13
+ * it from components.
14
+ */
15
+ import { createContext, useContext, useSyncExternalStore, type ReactNode } from "react";
16
+
17
+ export interface BootStepContext {
18
+ /**
19
+ * Aborted when the step times out. Stop work when it fires: a retry waits
20
+ * for a timed-out run to settle before running the step again.
21
+ */
22
+ signal: AbortSignal;
23
+ }
24
+
25
+ export interface BootStep {
26
+ /** Unique within the list; names the step in errors. */
27
+ id: string;
28
+ run: (context: BootStepContext) => Promise<unknown> | unknown;
29
+ /** Overrides the boot's `timeoutMs` for this step. */
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ export type BootStatus = "idle" | "booting" | "ready" | "failed";
34
+
35
+ export interface BootState {
36
+ status: BootStatus;
37
+ /** Set when `status` is `"failed"`. */
38
+ error: Error | null;
39
+ /** The id of the step that failed. */
40
+ failedStep: string | null;
41
+ /** Ids of the steps that have finished, in order. */
42
+ completed: readonly string[];
43
+ }
44
+
45
+ export interface BootOptions {
46
+ /** Shortest time the boot takes, so the splash doesn't flash. Default 0. */
47
+ minimumMs?: number;
48
+ /** Longest time one step may take. Default 10 000. */
49
+ timeoutMs?: number;
50
+ }
51
+
52
+ export interface BootController {
53
+ getState(): BootState;
54
+ subscribe(listener: () => void): () => void;
55
+ /** Run the steps not yet finished. Calling it while a run is in flight returns that run. */
56
+ start(): Promise<void>;
57
+ /** After a failure: run again from the step that failed. */
58
+ retry(): Promise<void>;
59
+ }
60
+
61
+ /** Thrown when a step takes longer than its timeout. */
62
+ export class BootTimeoutError extends Error {
63
+ readonly step: string;
64
+ constructor(step: string, ms: number, reason = "did not finish") {
65
+ super(`Boot step "${step}" ${reason} within ${ms} ms.`);
66
+ this.name = "BootTimeoutError";
67
+ this.step = step;
68
+ }
69
+ }
70
+
71
+ const DEFAULT_TIMEOUT_MS = 10_000;
72
+
73
+ export function toError(value: unknown): Error {
74
+ if (value instanceof Error) return value;
75
+ return new Error(typeof value === "string" ? value : "Unknown error", { cause: value });
76
+ }
77
+
78
+ /** Resolves "settled" when `promise` settles, "pending" after `ms`. */
79
+ function settledWithin(promise: Promise<unknown>, ms: number): Promise<"settled" | "pending"> {
80
+ let timer: ReturnType<typeof setTimeout> | undefined;
81
+ return Promise.race([
82
+ promise.then(
83
+ () => "settled" as const,
84
+ () => "settled" as const,
85
+ ),
86
+ new Promise<"pending">((resolve) => {
87
+ timer = setTimeout(() => resolve("pending"), ms);
88
+ }),
89
+ ]).finally(() => clearTimeout(timer));
90
+ }
91
+
92
+ /** The boot controller: runs `steps` once each, in order, with a timeout per step. */
93
+ export function createBoot(steps: readonly BootStep[], options: BootOptions = {}): BootController {
94
+ const seen = new Set<string>();
95
+ for (const step of steps) {
96
+ if (seen.has(step.id)) throw new Error(`Boot step "${step.id}" is listed twice.`);
97
+ seen.add(step.id);
98
+ }
99
+ const { minimumMs = 0, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
100
+ const listeners = new Set<() => void>();
101
+ let state: BootState = { status: "idle", error: null, failedStep: null, completed: [] };
102
+ let inFlight: Promise<void> | null = null;
103
+ // A step that timed out keeps running (JavaScript can't stop it; `signal`
104
+ // asks it to). A retry waits for it rather than running it twice at once.
105
+ const abandoned = new Map<string, Promise<"ok" | "failed">>();
106
+
107
+ const runStep = async (step: BootStep, ms: number): Promise<void> => {
108
+ const previous = abandoned.get(step.id);
109
+ if (previous) {
110
+ if ((await settledWithin(previous, ms)) === "pending") {
111
+ throw new BootTimeoutError(step.id, ms, "is still running from the last attempt");
112
+ }
113
+ abandoned.delete(step.id);
114
+ // The abandoned run finished after all: it counts.
115
+ if ((await previous) === "ok") return;
116
+ }
117
+ const controller = new AbortController();
118
+ const running = Promise.resolve().then(() => step.run({ signal: controller.signal }));
119
+ if ((await settledWithin(running, ms)) === "pending") {
120
+ controller.abort();
121
+ abandoned.set(step.id, running.then(() => "ok" as const, () => "failed" as const));
122
+ throw new BootTimeoutError(step.id, ms);
123
+ }
124
+ await running;
125
+ };
126
+
127
+ const set = (next: Partial<BootState>) => {
128
+ state = { ...state, ...next };
129
+ for (const listener of listeners) listener();
130
+ };
131
+
132
+ const run = async () => {
133
+ const started = Date.now();
134
+ set({ status: "booting", error: null, failedStep: null });
135
+ for (const step of steps) {
136
+ if (state.completed.includes(step.id)) continue;
137
+ try {
138
+ await runStep(step, step.timeoutMs ?? timeoutMs);
139
+ } catch (error) {
140
+ set({ status: "failed", error: toError(error), failedStep: step.id });
141
+ return;
142
+ }
143
+ set({ completed: [...state.completed, step.id] });
144
+ }
145
+ const wait = minimumMs - (Date.now() - started);
146
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
147
+ set({ status: "ready" });
148
+ };
149
+
150
+ const start = () => {
151
+ if (state.status === "ready") return Promise.resolve();
152
+ if (!inFlight) {
153
+ inFlight = run().finally(() => {
154
+ inFlight = null;
155
+ });
156
+ }
157
+ return inFlight;
158
+ };
159
+
160
+ return {
161
+ getState: () => state,
162
+ subscribe(listener) {
163
+ listeners.add(listener);
164
+ return () => listeners.delete(listener);
165
+ },
166
+ start,
167
+ retry: start,
168
+ };
169
+ }
170
+
171
+ const BootContext = createContext<BootController | null>(null);
172
+
173
+ /** Makes a boot readable with `useBoot()`. */
174
+ export function BootProvider({ boot, children }: { boot: BootController; children: ReactNode }) {
175
+ return <BootContext.Provider value={boot}>{children}</BootContext.Provider>;
176
+ }
177
+
178
+ export interface UseBootReturn extends BootState {
179
+ retry: () => void;
180
+ }
181
+
182
+ /** The boot's status, and `retry` after a failure. Inside `GenesisNativeApp` or a `BootProvider`. */
183
+ export function useBoot(): UseBootReturn {
184
+ const boot = useContext(BootContext);
185
+ if (!boot) throw new Error("useBoot must be used inside <GenesisNativeApp> or <BootProvider>.");
186
+ const state = useSyncExternalStore(boot.subscribe, boot.getState, boot.getState);
187
+ return { ...state, retry: () => void boot.retry() };
188
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Development-only warnings from the libraries a Genesis native app is built on, that the app
3
+ * can't act on, kept out of LogBox.
4
+ *
5
+ * Why it matters: in React Native 0.88 (Expo SDK 58), LogBox no longer lists warnings while no
6
+ * debugger is attached. The first one instead shows a pill, "Open debugger to view warnings.", pinned to the bottom of the screen,
7
+ * over the native tab bar (`expo-router/native-tabs`). A tap meant for a tab lands on the pill
8
+ * (which opens the debugger and goes away), so the first tab tap after a launch, or after a link
9
+ * opened the app, seems to do nothing. In the starter on Expo SDK 58 the warning was React
10
+ * Native's own deprecation of `ImageBackground`, read by NativeWind's runtime
11
+ * (`react-native-css-interop` 0.2 registers every core component at start-up).
12
+ *
13
+ * Only warnings with no fix in the app belong here: one the app causes should be fixed, and still
14
+ * shows. Each pattern names where it comes from and when to remove it. Release builds have no
15
+ * LogBox, so this does nothing there.
16
+ *
17
+ * It has to run before any library loads: NativeWind's runtime warns while the first screen
18
+ * module is still being required, long before `GenesisNativeApp` renders. So the app's entry
19
+ * file calls it, then requires expo-router's entry (the starter's `index.ts`):
20
+ *
21
+ * import { LogBox } from "react-native";
22
+ * import { ignoreKnownDependencyWarnings } from "@marktiderman/genesis-native/dev-warnings";
23
+ * ignoreKnownDependencyWarnings(LogBox, __DEV__);
24
+ * require("expo-router/entry");
25
+ */
26
+
27
+ export interface LogBoxLike {
28
+ ignoreLogs(patterns: ReadonlyArray<string | RegExp>): void;
29
+ }
30
+
31
+ /** The warnings, matched on their text. */
32
+ export const KNOWN_DEPENDENCY_WARNINGS: readonly RegExp[] = [
33
+ // react-native-css-interop 0.2.x (NativeWind 4) reads `ImageBackground` from React Native's
34
+ // index, whose getter warns from 0.88. Remove once css-interop stops touching it.
35
+ /^ImageBackground is deprecated and will be removed in a future release\./,
36
+ ];
37
+
38
+ /** Keep the known dependency warnings out of LogBox (development only). Returns whether it did. */
39
+ export function ignoreKnownDependencyWarnings(logBox: LogBoxLike | undefined, dev: boolean): boolean {
40
+ if (!dev || !logBox) return false;
41
+ logBox.ignoreLogs(KNOWN_DEPENDENCY_WARNINGS);
42
+ return true;
43
+ }