@gusnips/native 0.0.0-stage → 0.1.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +310 -2
  3. package/dist/device-locale.d.ts +15 -0
  4. package/dist/device-locale.d.ts.map +1 -0
  5. package/dist/device-locale.js +27 -0
  6. package/dist/device-locale.js.map +1 -0
  7. package/dist/i18n.d.ts +34 -0
  8. package/dist/i18n.d.ts.map +1 -0
  9. package/dist/i18n.js +63 -0
  10. package/dist/i18n.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +18 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/network.d.ts +18 -0
  16. package/dist/network.d.ts.map +1 -0
  17. package/dist/network.js +35 -0
  18. package/dist/network.js.map +1 -0
  19. package/dist/node/bin/gusnips-native-css.d.ts +3 -0
  20. package/dist/node/bin/gusnips-native-css.d.ts.map +1 -0
  21. package/dist/node/bin/gusnips-native-css.js +75 -0
  22. package/dist/node/bin/gusnips-native-css.js.map +1 -0
  23. package/dist/node/css.d.ts +44 -0
  24. package/dist/node/css.d.ts.map +1 -0
  25. package/dist/node/css.js +181 -0
  26. package/dist/node/css.js.map +1 -0
  27. package/dist/node/metro.d.ts +51 -0
  28. package/dist/node/metro.d.ts.map +1 -0
  29. package/dist/node/metro.js +100 -0
  30. package/dist/node/metro.js.map +1 -0
  31. package/dist/query.d.ts +13 -0
  32. package/dist/query.d.ts.map +1 -0
  33. package/dist/query.js +37 -0
  34. package/dist/query.js.map +1 -0
  35. package/dist/sse.d.ts +47 -0
  36. package/dist/sse.d.ts.map +1 -0
  37. package/dist/sse.js +135 -0
  38. package/dist/sse.js.map +1 -0
  39. package/dist/supabase.d.ts +18 -0
  40. package/dist/supabase.d.ts.map +1 -0
  41. package/dist/supabase.js +29 -0
  42. package/dist/supabase.js.map +1 -0
  43. package/dist/theme.d.ts +25 -0
  44. package/dist/theme.d.ts.map +1 -0
  45. package/dist/theme.js +99 -0
  46. package/dist/theme.js.map +1 -0
  47. package/package.json +150 -4
  48. package/src/device-locale.test.ts +33 -0
  49. package/src/device-locale.ts +28 -0
  50. package/src/i18n.ts +91 -0
  51. package/src/index.ts +18 -0
  52. package/src/network.ts +37 -0
  53. package/src/node/bin/gusnips-native-css.ts +84 -0
  54. package/src/node/css.ts +246 -0
  55. package/src/node/metro.test.ts +66 -0
  56. package/src/node/metro.ts +143 -0
  57. package/src/query.ts +41 -0
  58. package/src/sse.test.ts +195 -0
  59. package/src/sse.ts +174 -0
  60. package/src/supabase.ts +29 -0
  61. package/src/theme.test.ts +92 -0
  62. package/src/theme.ts +132 -0
@@ -0,0 +1,195 @@
1
+ import { ApiError, type SessionAdapter, type SseFrame } from "@gusnips/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { openSseStream, type SseStreamOptions } from "./sse.ts";
4
+
5
+ /** Just the part of XMLHttpRequest the stream touches, driven by hand. */
6
+ class FakeXhr {
7
+ static opened: FakeXhr[] = [];
8
+ status = 200;
9
+ responseText = "";
10
+ responseHeaders: Record<string, string> = {};
11
+ requestHeaders: Record<string, string> = {};
12
+ sent: string | null | undefined;
13
+ aborted = false;
14
+ onprogress: (() => void) | null = null;
15
+ onload: (() => void) | null = null;
16
+ onerror: (() => void) | null = null;
17
+ ontimeout: (() => void) | null = null;
18
+ open() {
19
+ FakeXhr.opened.push(this);
20
+ }
21
+ setRequestHeader(name: string, value: string) {
22
+ this.requestHeaders[name] = value;
23
+ }
24
+ getResponseHeader(name: string) {
25
+ return this.responseHeaders[name] ?? null;
26
+ }
27
+ send(body: string | null) {
28
+ this.sent = body;
29
+ }
30
+ abort() {
31
+ this.aborted = true;
32
+ }
33
+ /** The server wrote more: `responseText` grows, as it does on React Native. */
34
+ arrive(text: string) {
35
+ this.responseText += text;
36
+ this.onprogress?.();
37
+ }
38
+ }
39
+
40
+ const settle = () => new Promise((done) => setTimeout(done, 0));
41
+
42
+ /** A session whose refresh hands out `fresh` once, the way the API client's adapter does. */
43
+ function sessionWith(fresh: string | null): SessionAdapter & { refresh: ReturnType<typeof vi.fn> } {
44
+ return {
45
+ getToken: async () => "old",
46
+ refresh: vi.fn(async () => ({ token: fresh, reachedAuth: true })),
47
+ signOut: async () => {},
48
+ };
49
+ }
50
+
51
+ function open(overrides: Partial<SseStreamOptions> = {}) {
52
+ const frames: SseFrame[] = [];
53
+ const onError = vi.fn<(error: Error) => void>();
54
+ const onClose = vi.fn();
55
+ const stream = openSseStream({
56
+ url: "https://api.test/stream",
57
+ body: { message: "hi" },
58
+ session: sessionWith("new"),
59
+ headers: () => ({ "Accept-Language": "pt-BR" }),
60
+ onFrame: (frame) => frames.push(frame),
61
+ onError,
62
+ onClose,
63
+ ...overrides,
64
+ });
65
+ return { stream, frames, onError, onClose };
66
+ }
67
+
68
+ beforeEach(() => {
69
+ FakeXhr.opened = [];
70
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
71
+ });
72
+ afterEach(() => vi.unstubAllGlobals());
73
+
74
+ describe("openSseStream", () => {
75
+ it("feeds each new slice once, and joins a frame split across progress events", async () => {
76
+ const { frames, onClose } = open();
77
+ await settle();
78
+ const xhr = FakeXhr.opened[0]!;
79
+ expect(xhr.requestHeaders).toMatchObject({
80
+ Authorization: "Bearer old",
81
+ Accept: "text/event-stream",
82
+ "Accept-Language": "pt-BR",
83
+ });
84
+ expect(xhr.sent).toBe('{"message":"hi"}');
85
+
86
+ xhr.arrive('event: delta\ndata: {"text":"Hel');
87
+ expect(frames).toEqual([]);
88
+ xhr.arrive('lo"}\n\nevent: delta\ndata: {"text":" there"}\n\n');
89
+ xhr.arrive(""); // a progress event with nothing new delivers nothing twice
90
+ expect(frames.map((frame) => frame.data)).toEqual(['{"text":"Hello"}', '{"text":" there"}']);
91
+
92
+ // The last frame arrives with the close and without its blank line.
93
+ xhr.responseText += "event: done\ndata: {}";
94
+ xhr.onload?.();
95
+ expect(frames.map((frame) => frame.event)).toEqual(["delta", "delta", "done"]);
96
+ expect(onClose).toHaveBeenCalledOnce();
97
+ });
98
+
99
+ it("sends a GET with no body", async () => {
100
+ open({ method: "GET" });
101
+ await settle();
102
+ expect(FakeXhr.opened[0]!.sent).toBeNull();
103
+ expect(FakeXhr.opened[0]!.requestHeaders["Content-Type"]).toBeUndefined();
104
+ });
105
+
106
+ it("throws a refused handshake as the API client's ApiError", async () => {
107
+ const { onError, onClose } = open();
108
+ await settle();
109
+ const xhr = FakeXhr.opened[0]!;
110
+ xhr.status = 402;
111
+ xhr.responseText = '{"error":{"code":"CREDITS_EXHAUSTED","message":"No credits left"}}';
112
+ xhr.responseHeaders = { "x-request-id": "req_1", "retry-after": "30" };
113
+ xhr.onload?.();
114
+ const error = onError.mock.calls[0]![0];
115
+ expect(error).toBeInstanceOf(ApiError);
116
+ expect(error).toMatchObject({
117
+ status: 402,
118
+ code: "CREDITS_EXHAUSTED",
119
+ requestId: "req_1",
120
+ retryAfterSecs: 30,
121
+ });
122
+ expect(onClose).toHaveBeenCalledOnce();
123
+ });
124
+
125
+ it("reads a body that is not JSON as a bare status", async () => {
126
+ const { onError } = open();
127
+ await settle();
128
+ const xhr = FakeXhr.opened[0]!;
129
+ xhr.status = 502;
130
+ xhr.responseText = "<html>Bad gateway</html>";
131
+ xhr.onload?.();
132
+ expect(onError.mock.calls[0]![0]).toMatchObject({ status: 502, code: undefined });
133
+ });
134
+
135
+ it("refreshes once through the session on a 401 and opens again with the new token", async () => {
136
+ const session = sessionWith("new");
137
+ const { frames, onError } = open({ session });
138
+ await settle();
139
+ FakeXhr.opened[0]!.status = 401;
140
+ FakeXhr.opened[0]!.onload?.();
141
+ await settle();
142
+
143
+ const retry = FakeXhr.opened[1]!;
144
+ expect(retry.requestHeaders.Authorization).toBe("Bearer new");
145
+ retry.status = 401;
146
+ retry.onload?.();
147
+ await settle();
148
+ // Once: a second 401 is the answer, not a reason to refresh again.
149
+ expect(session.refresh).toHaveBeenCalledOnce();
150
+ expect(FakeXhr.opened).toHaveLength(2);
151
+ expect(onError.mock.calls[0]![0]).toMatchObject({ status: 401 });
152
+ expect(frames).toEqual([]);
153
+ });
154
+
155
+ it("ends with the 401 when there is no session to refresh", async () => {
156
+ const { onError, onClose } = open({ session: sessionWith(null) });
157
+ await settle();
158
+ FakeXhr.opened[0]!.status = 401;
159
+ FakeXhr.opened[0]!.onload?.();
160
+ await settle();
161
+ expect(FakeXhr.opened).toHaveLength(1);
162
+ expect(onError.mock.calls[0]![0]).toMatchObject({ status: 401 });
163
+ expect(onClose).toHaveBeenCalledOnce();
164
+ });
165
+
166
+ it("reports a lost connection as a plain Error, not an answer", async () => {
167
+ const { onError } = open();
168
+ await settle();
169
+ FakeXhr.opened[0]!.onerror?.();
170
+ const error = onError.mock.calls[0]![0];
171
+ expect(error).not.toBeInstanceOf(ApiError);
172
+ });
173
+
174
+ it("delivers nothing after abort", async () => {
175
+ const { stream, frames, onError, onClose } = open();
176
+ await settle();
177
+ const xhr = FakeXhr.opened[0]!;
178
+ stream.abort();
179
+ expect(xhr.aborted).toBe(true);
180
+ expect(stream.aborted).toBe(true);
181
+ xhr.arrive("data: late\n\n");
182
+ xhr.onerror?.();
183
+ xhr.onload?.();
184
+ expect(frames).toEqual([]);
185
+ expect(onError).not.toHaveBeenCalled();
186
+ expect(onClose).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it("never opens when aborted before the headers are ready", async () => {
190
+ const { stream } = open();
191
+ stream.abort();
192
+ await settle();
193
+ expect(FakeXhr.opened).toHaveLength(0);
194
+ });
195
+ });
package/src/sse.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Server-sent events on React Native, over `XMLHttpRequest`.
3
+ *
4
+ * React Native's `fetch` never gives a response body you can read as it arrives: there is no
5
+ * `ReadableStream` on it, so `readSseStream` from `@gusnips/react` has nothing to read. XHR does
6
+ * fire `onprogress` with a `responseText` that grows as bytes land, and that is the channel every
7
+ * RN app streaming from a server ends up on. The framing is `@gusnips/react`'s `createSseParser`,
8
+ * the same state machine the web reads with. Only the feed is different: here it is handed the new
9
+ * slice of `responseText` on each progress event.
10
+ *
11
+ * A second transport has a cost the kit already paid for once: it drifts from the API client. So
12
+ * this one keeps to the client's contract where it can. A refused handshake throws the client's own
13
+ * `ApiError`, read from the same envelope and the same `Retry-After` header, so a 402 on a stream
14
+ * gets the sentence and the button any other 402 in the app gets. The token and the 401 refresh come
15
+ * from the same `SessionAdapter` the client is built on. Single flight is the adapter's job and this
16
+ * adds none of its own, so a stream opening mid-load joins the refresh every other request is
17
+ * waiting on instead of racing it. GoTrue rotates the refresh token on use, and the loser of that race would sign the
18
+ * person out. The donor kept a hand-rolled single flight beside its client for this; it existed only
19
+ * because the adapter did not have one.
20
+ *
21
+ * No reconnect. The donor's chat store had its own recovery (it refetches the transcript when a
22
+ * stream breaks), and a loop here would have been a second one nobody asked for.
23
+ */
24
+ import { isApiError } from "@gusnips/http";
25
+ import { parseRetryAfter } from "@gusnips/http/retry";
26
+ import { ApiError, createSseParser, type SessionAdapter, type SseFrame } from "@gusnips/react";
27
+
28
+ export interface SseStreamOptions {
29
+ /** The whole address of the stream, `https://api.example.com/chat/stream`. */
30
+ url: string;
31
+ /** POST, the default, sends `body` as JSON. GET sends nothing. */
32
+ method?: "GET" | "POST";
33
+ body?: unknown;
34
+ /**
35
+ * The adapter your `createApiClient` uses. Its token goes out as `Authorization: Bearer`, and a
36
+ * 401 calls its `refresh()` once and opens again with the new token. Leave it out for a stream
37
+ * that needs no sign-in.
38
+ */
39
+ session?: SessionAdapter;
40
+ /** Extra headers, computed when the request opens, like the API client's `headers`. */
41
+ headers?: () => Record<string, string>;
42
+ /** One frame off the wire. `data` is raw text: parse it the way your server writes it. */
43
+ onFrame: (frame: SseFrame) => void;
44
+ /**
45
+ * The stream failed. An `ApiError` when the server answered with a refusal, so the describer
46
+ * explains it like any other; a plain `Error` when nothing came back at all.
47
+ */
48
+ onError?: (error: Error) => void;
49
+ /** The stream ended, cleanly or not, without `abort()` being called. */
50
+ onClose?: () => void;
51
+ }
52
+
53
+ export interface SseStream {
54
+ readonly aborted: boolean;
55
+ /** Close the socket. No frame, error or close is delivered after this. */
56
+ abort(): void;
57
+ }
58
+
59
+ /** The request id header `createApiClient` reads by default. */
60
+ const REQUEST_ID_HEADER = "x-request-id";
61
+
62
+ /**
63
+ * Open a stream. It starts at once; keep the handle to stop it.
64
+ *
65
+ * ```ts
66
+ * const stream = openSseStream({
67
+ * url: `${API_URL}/chat/stream`,
68
+ * body: { message },
69
+ * session, // the same adapter createApiClient uses
70
+ * onFrame: ({ event, data }) => handle(event, JSON.parse(data)),
71
+ * });
72
+ * ```
73
+ *
74
+ * ponytail: XHR keeps the whole response in `responseText`, so memory grows with the length of the
75
+ * stream. A chat turn is fine. A feed left open all day is not; that needs a native streaming module.
76
+ */
77
+ export function openSseStream(options: SseStreamOptions): SseStream {
78
+ const { url, method = "POST", body, session, headers, onFrame, onError, onClose } = options;
79
+ let aborted = false;
80
+ let refreshed = false;
81
+ let current: XMLHttpRequest | null = null;
82
+
83
+ const fail = (error: Error) => {
84
+ if (aborted) return;
85
+ onError?.(error);
86
+ onClose?.();
87
+ };
88
+ const asError = (thrown: unknown, fallback: string) =>
89
+ thrown instanceof Error ? thrown : new Error(fallback);
90
+
91
+ function start(token: string | null): void {
92
+ if (aborted) return;
93
+ const xhr = new XMLHttpRequest();
94
+ current = xhr;
95
+ let read = 0;
96
+ const parser = createSseParser((frame) => {
97
+ if (!aborted) onFrame(frame);
98
+ });
99
+
100
+ xhr.open(method, url);
101
+ if (method !== "GET") xhr.setRequestHeader("Content-Type", "application/json");
102
+ xhr.setRequestHeader("Accept", "text/event-stream");
103
+ for (const [name, value] of Object.entries(headers?.() ?? {}))
104
+ xhr.setRequestHeader(name, value);
105
+ if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
106
+
107
+ // `responseText` is everything so far, not the new part. Feed only what was not fed yet, or
108
+ // every frame is delivered again on every progress event.
109
+ const drain = () => {
110
+ if (aborted || xhr.responseText.length <= read) return;
111
+ const next = xhr.responseText.slice(read);
112
+ read = xhr.responseText.length;
113
+ parser.feed(next);
114
+ };
115
+ xhr.onprogress = drain;
116
+
117
+ xhr.onload = () => {
118
+ if (aborted) return;
119
+ if (xhr.status === 401 && session && !refreshed) {
120
+ refreshed = true;
121
+ const refused = refusalOf(xhr);
122
+ // No token back means no session to retry with, whether auth refused or never answered.
123
+ // The stream ends with the 401; signing out stays the API client's call, so a dead session
124
+ // has one place that ends it.
125
+ session.refresh().then(
126
+ ({ token: fresh }) => (fresh ? start(fresh) : fail(refused)),
127
+ (thrown: unknown) => fail(asError(thrown, "Could not refresh the session")),
128
+ );
129
+ return;
130
+ }
131
+ if (xhr.status < 200 || xhr.status >= 300) {
132
+ fail(refusalOf(xhr));
133
+ return;
134
+ }
135
+ // A last frame the server closed without its blank line. Two newlines close it whether the
136
+ // stream ended mid-frame or after one newline; after a complete frame they add nothing.
137
+ drain();
138
+ parser.feed("\n\n");
139
+ onClose?.();
140
+ };
141
+ xhr.onerror = () => fail(new Error("The stream lost its connection"));
142
+ xhr.ontimeout = () => fail(new Error("The stream timed out"));
143
+
144
+ xhr.send(method === "GET" ? null : JSON.stringify(body ?? {}));
145
+ }
146
+
147
+ (session ? session.getToken() : Promise.resolve(null)).then(start, (thrown: unknown) =>
148
+ fail(asError(thrown, "Could not read the session")),
149
+ );
150
+
151
+ return {
152
+ get aborted() {
153
+ return aborted;
154
+ },
155
+ abort() {
156
+ aborted = true;
157
+ current?.abort();
158
+ },
159
+ };
160
+ }
161
+
162
+ /** The refusal as the API client would have thrown it: the envelope, the request id, the wait. */
163
+ function refusalOf(xhr: XMLHttpRequest): ApiError {
164
+ let parsed: unknown = null;
165
+ try {
166
+ parsed = JSON.parse(xhr.responseText);
167
+ } catch {
168
+ // Not JSON, such as a gateway's HTML page. The status is all there is.
169
+ }
170
+ return new ApiError(xhr.status, isApiError(parsed) ? parsed.error : null, {
171
+ requestId: xhr.getResponseHeader(REQUEST_ID_HEADER) ?? undefined,
172
+ retryAfterSecs: parseRetryAfter(xhr.getResponseHeader("retry-after")),
173
+ });
174
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where supabase-js keeps the session on a phone.
3
+ *
4
+ * AsyncStorage and not the keychain (or a SecureStore): a session is the access token, the refresh
5
+ * token and the user's metadata in one JSON value, and it routinely passes the roughly 2 KB a
6
+ * platform keystore takes per value. AsyncStorage is private to the app on both platforms, and it is
7
+ * what Supabase's own React Native guide uses.
8
+ */
9
+ import AsyncStorage from "@react-native-async-storage/async-storage";
10
+ import { supabaseStorageKey, type SupabaseSessionStorage } from "@gusnips/react/supabase";
11
+
12
+ /**
13
+ * The phone's `supabaseAuthStorage`. Build it once and pass it both to `createClient`'s `auth` and
14
+ * to `signOutEvenOffline`, so the sign-out clears the exact key the client reads:
15
+ *
16
+ * ```ts
17
+ * export const authStorage = supabaseAuthStorage(SUPABASE_URL);
18
+ * export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
19
+ * auth: { ...authStorage, detectSessionInUrl: false, flowType: "pkce" },
20
+ * });
21
+ * ```
22
+ *
23
+ * The key is the one supabase-js picks for itself, so moving an app onto this signs nobody out.
24
+ * On a phone, pass `flowType: "pkce"` as above: a sign-in comes back through a custom scheme any app
25
+ * can claim, and under the implicit flow that URL carries the refresh token itself.
26
+ */
27
+ export function supabaseAuthStorage(supabaseUrl: string): SupabaseSessionStorage {
28
+ return { storage: AsyncStorage, storageKey: supabaseStorageKey(supabaseUrl) };
29
+ }
@@ -0,0 +1,92 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // React Native and AsyncStorage do not load under Node. These stand in for the parts the theme
4
+ // touches, and behave the way RN 0.86's Appearance does: `setColorScheme` changes what
5
+ // `getColorScheme` answers at once, and "unspecified" hands back the OS scheme.
6
+ const device = vi.hoisted(() => ({
7
+ os: "light" as "light" | "dark",
8
+ override: null as "light" | "dark" | null,
9
+ foreground: [] as ((state: string) => void)[],
10
+ stored: new Map<string, string>(),
11
+ read: null as Promise<string | null> | null,
12
+ }));
13
+
14
+ vi.mock("react-native", () => ({
15
+ Appearance: {
16
+ getColorScheme: () => device.override ?? device.os,
17
+ setColorScheme: (scheme: string) => {
18
+ device.override = scheme === "unspecified" ? null : (scheme as "light" | "dark");
19
+ },
20
+ addChangeListener: () => ({ remove() {} }),
21
+ },
22
+ AppState: {
23
+ addEventListener: (_type: string, listener: (state: string) => void) => {
24
+ device.foreground.push(listener);
25
+ return { remove() {} };
26
+ },
27
+ },
28
+ }));
29
+ vi.mock("@react-native-async-storage/async-storage", () => ({
30
+ default: {
31
+ getItem: (key: string) => device.read ?? Promise.resolve(device.stored.get(key) ?? null),
32
+ setItem: async (key: string, value: string) => void device.stored.set(key, value),
33
+ },
34
+ }));
35
+
36
+ const { createNativeTheme } = await import("./theme.ts");
37
+
38
+ beforeEach(() => {
39
+ device.os = "light";
40
+ device.override = null;
41
+ device.foreground = [];
42
+ device.stored.clear();
43
+ device.read = null;
44
+ });
45
+
46
+ const settle = () => new Promise((done) => setTimeout(done, 0));
47
+
48
+ describe("createNativeTheme", () => {
49
+ it("applies the stored choice at start", async () => {
50
+ device.stored.set("theme", "dark");
51
+ const theme = createNativeTheme({ key: "theme" });
52
+ expect(theme.getSnapshot()).toEqual({ mode: "system", resolved: "light" });
53
+ theme.start();
54
+ await settle();
55
+ expect(theme.getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
56
+ });
57
+
58
+ it("keeps a choice made while the stored one is still loading", async () => {
59
+ device.stored.set("theme", "light");
60
+ let finishRead!: (value: string | null) => void;
61
+ device.read = new Promise((done) => (finishRead = done));
62
+ const theme = createNativeTheme({ key: "theme" });
63
+ theme.start();
64
+ theme.setMode("dark");
65
+ finishRead("light");
66
+ await settle();
67
+ expect(theme.getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
68
+ expect(device.stored.get("theme")).toBe("dark");
69
+ });
70
+
71
+ it("follows the OS again on system, and keeps one snapshot while nothing changes", () => {
72
+ const theme = createNativeTheme({ key: "theme" });
73
+ theme.setMode("dark");
74
+ expect(theme.getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
75
+ theme.setMode("system");
76
+ expect(theme.getSnapshot()).toEqual({ mode: "system", resolved: "light" });
77
+ // useSyncExternalStore re-renders on a new object, so an unchanged theme must not make one.
78
+ const before = theme.getSnapshot();
79
+ theme.setMode("system");
80
+ expect(theme.getSnapshot()).toBe(before);
81
+ });
82
+
83
+ it("puts the choice back when the app returns to the foreground", async () => {
84
+ const theme = createNativeTheme({ key: "theme" });
85
+ theme.start();
86
+ await settle();
87
+ theme.setMode("dark");
88
+ device.override = null; // Android recreated the activity and dropped the override
89
+ for (const listener of device.foreground) listener("active");
90
+ expect(device.override).toBe("dark");
91
+ });
92
+ });
package/src/theme.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Light, dark, or whatever the phone says, remembered across launches.
3
+ *
4
+ * The phone's half of `@gusnips/react/theme`: the same three modes, the same snapshot and the same
5
+ * `useTheme`, so a settings screen written against one reads the same against the other. What
6
+ * differs is where it lands. On the web the theme is a class on `<html>`; here it is
7
+ * `Appearance.setColorScheme`, which is what `useColorScheme()` and react-native-css's `dark:`
8
+ * variant both read. `"system"` clears the override, so the OS decides again, live.
9
+ *
10
+ * Two things the donor's store taught:
11
+ *
12
+ * - **Android drops the override when the activity is recreated** under a running JS context (a
13
+ * system overlay, a configuration change), and the app comes back in the OS scheme with the old
14
+ * choice still in memory. So the choice is applied again every time the app returns to the
15
+ * foreground. No storage read for that, only the mode already held.
16
+ * - **A choice made while the stored one is still loading wins.** The read is async. A tap on
17
+ * "Dark" in the first second of a launch must not be undone by the stored "Light" arriving after
18
+ * it, which the donor's store would have done.
19
+ */
20
+ import AsyncStorage from "@react-native-async-storage/async-storage";
21
+ import type { ResolvedTheme, Theme, ThemeMode, ThemeSnapshot } from "@gusnips/react/theme";
22
+ import { useSyncExternalStore } from "react";
23
+ import { AppState, Appearance } from "react-native";
24
+
25
+ export type { ResolvedTheme, ThemeMode, ThemeSnapshot };
26
+
27
+ export interface NativeThemeOptions {
28
+ /** The AsyncStorage key holding the choice. Keep the key your app already uses. */
29
+ key: string;
30
+ /** What an empty or unrecognised key means. Defaults to `"system"`. */
31
+ defaultMode?: ThemeMode;
32
+ }
33
+
34
+ export interface NativeTheme extends Theme {
35
+ /**
36
+ * Load the stored choice, apply it, and keep it applied each time the app comes back to the
37
+ * foreground. Returns the function that stops it, so it fits a root `useEffect` as it is:
38
+ * `useEffect(() => theme.start(), [])`.
39
+ */
40
+ start(): () => void;
41
+ }
42
+
43
+ const MODES: readonly ThemeMode[] = ["light", "dark", "system"];
44
+
45
+ const isMode = (value: string | null): value is ThemeMode =>
46
+ value !== null && (MODES as readonly string[]).includes(value);
47
+
48
+ /**
49
+ * Nothing runs at import: the snapshot is read on first use, and storage only in `start()`.
50
+ *
51
+ * ```ts
52
+ * export const theme = createNativeTheme({ key: "app:theme" });
53
+ * ```
54
+ */
55
+ export function createNativeTheme({
56
+ key,
57
+ defaultMode = "system",
58
+ }: NativeThemeOptions): NativeTheme {
59
+ let mode: ThemeMode = defaultMode;
60
+ let chosen = false;
61
+ let snapshot: ThemeSnapshot | null = null;
62
+ const listeners = new Set<() => void>();
63
+ let appearance: { remove(): void } | null = null;
64
+
65
+ const resolved = (): ResolvedTheme => (Appearance.getColorScheme() === "dark" ? "dark" : "light");
66
+
67
+ function getSnapshot(): ThemeSnapshot {
68
+ snapshot ??= { mode, resolved: resolved() };
69
+ return snapshot;
70
+ }
71
+
72
+ // `useSyncExternalStore` compares snapshots by identity, so a new object goes out only when
73
+ // something a screen can see has changed.
74
+ function publish(): void {
75
+ const next = { mode, resolved: resolved() };
76
+ if (snapshot && snapshot.mode === next.mode && snapshot.resolved === next.resolved) return;
77
+ snapshot = next;
78
+ for (const listener of listeners) listener();
79
+ }
80
+
81
+ // `setColorScheme` updates what `getColorScheme` answers at once, and emits no change event of
82
+ // its own, so this publishes rather than waiting for one.
83
+ function apply(): void {
84
+ Appearance.setColorScheme(mode === "system" ? "unspecified" : mode);
85
+ publish();
86
+ }
87
+
88
+ function setMode(next: ThemeMode): void {
89
+ chosen = true;
90
+ mode = next;
91
+ apply();
92
+ AsyncStorage.setItem(key, next).catch(() => {
93
+ // Not remembered across launches, and still applied for this one.
94
+ });
95
+ }
96
+
97
+ function subscribe(listener: () => void): () => void {
98
+ listeners.add(listener);
99
+ // The OS switching scheme (a phone that goes dark at sunset) while the mode is "system".
100
+ appearance ??= Appearance.addChangeListener(publish);
101
+ return () => {
102
+ listeners.delete(listener);
103
+ if (listeners.size === 0) {
104
+ appearance?.remove();
105
+ appearance = null;
106
+ }
107
+ };
108
+ }
109
+
110
+ function start(): () => void {
111
+ AsyncStorage.getItem(key).then(
112
+ (stored) => {
113
+ if (chosen) return;
114
+ mode = isMode(stored) ? stored : defaultMode;
115
+ apply();
116
+ },
117
+ () => {
118
+ // Nothing readable: the default stands, and a choice made now is still applied.
119
+ },
120
+ );
121
+ const foreground = AppState.addEventListener("change", (state) => {
122
+ if (state === "active") apply();
123
+ });
124
+ return () => foreground.remove();
125
+ }
126
+
127
+ function useTheme() {
128
+ return { ...useSyncExternalStore(subscribe, getSnapshot), setMode };
129
+ }
130
+
131
+ return { useTheme, setMode, getSnapshot, start };
132
+ }