@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.
- package/LICENSE +21 -0
- package/README.md +310 -2
- package/dist/device-locale.d.ts +15 -0
- package/dist/device-locale.d.ts.map +1 -0
- package/dist/device-locale.js +27 -0
- package/dist/device-locale.js.map +1 -0
- package/dist/i18n.d.ts +34 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +63 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +18 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +35 -0
- package/dist/network.js.map +1 -0
- package/dist/node/bin/gusnips-native-css.d.ts +3 -0
- package/dist/node/bin/gusnips-native-css.d.ts.map +1 -0
- package/dist/node/bin/gusnips-native-css.js +75 -0
- package/dist/node/bin/gusnips-native-css.js.map +1 -0
- package/dist/node/css.d.ts +44 -0
- package/dist/node/css.d.ts.map +1 -0
- package/dist/node/css.js +181 -0
- package/dist/node/css.js.map +1 -0
- package/dist/node/metro.d.ts +51 -0
- package/dist/node/metro.d.ts.map +1 -0
- package/dist/node/metro.js +100 -0
- package/dist/node/metro.js.map +1 -0
- package/dist/query.d.ts +13 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +37 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +47 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +135 -0
- package/dist/sse.js.map +1 -0
- package/dist/supabase.d.ts +18 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +29 -0
- package/dist/supabase.js.map +1 -0
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +99 -0
- package/dist/theme.js.map +1 -0
- package/package.json +150 -4
- package/src/device-locale.test.ts +33 -0
- package/src/device-locale.ts +28 -0
- package/src/i18n.ts +91 -0
- package/src/index.ts +18 -0
- package/src/network.ts +37 -0
- package/src/node/bin/gusnips-native-css.ts +84 -0
- package/src/node/css.ts +246 -0
- package/src/node/metro.test.ts +66 -0
- package/src/node/metro.ts +143 -0
- package/src/query.ts +41 -0
- package/src/sse.test.ts +195 -0
- package/src/sse.ts +174 -0
- package/src/supabase.ts +29 -0
- package/src/theme.test.ts +92 -0
- package/src/theme.ts +132 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @gusnips/native: what a bare React Native app needs beside @gusnips/react.
|
|
2
|
+
//
|
|
3
|
+
// The rule that picks the subpaths is the kit's invariant 15, checked by `bun run exports`: a peer
|
|
4
|
+
// marked optional must not be reachable from this entry. Every subpath below needs one more package
|
|
5
|
+
// with native code or a build tool in it, and an app installs that package only by importing the
|
|
6
|
+
// thing that uses it:
|
|
7
|
+
//
|
|
8
|
+
// @gusnips/native/theme createNativeTheme → @react-native-async-storage/async-storage
|
|
9
|
+
// @gusnips/native/network useIsOffline → @react-native-community/netinfo
|
|
10
|
+
// @gusnips/native/query installQueryManagers → @tanstack/react-query, netinfo
|
|
11
|
+
// @gusnips/native/i18n createNativeLanguage → react-native-localize, async-storage
|
|
12
|
+
// @gusnips/native/supabase supabaseAuthStorage → async-storage, @supabase/supabase-js
|
|
13
|
+
// @gusnips/native/metro nativeMetroConfig → Node, at bundle time
|
|
14
|
+
// @gusnips/native/css buildNativeCss → Node, tailwindcss, react-native-css
|
|
15
|
+
//
|
|
16
|
+
// What is left here needs only the required peers.
|
|
17
|
+
|
|
18
|
+
export { openSseStream, type SseStream, type SseStreamOptions } from "./sse.ts";
|
package/src/network.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the phone offline? NetInfo answers, with one rule both helpers here share.
|
|
3
|
+
*
|
|
4
|
+
* `isConnected === false` is offline, and nothing else is. NetInfo reports `null` while it has not
|
|
5
|
+
* worked it out yet, which happens at every launch; reading that as offline puts an offline banner
|
|
6
|
+
* on screen for the first moment of every session.
|
|
7
|
+
*/
|
|
8
|
+
import NetInfo, { type NetInfoState } from "@react-native-community/netinfo";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
|
|
11
|
+
const offline = (state: NetInfoState) => state.isConnected === false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True while the phone has no connection.
|
|
15
|
+
*
|
|
16
|
+
* React Query already pauses on its own once `installQueryManagers` from `@gusnips/native/query`
|
|
17
|
+
* is running. This is for a screen that should SAY it is offline instead of showing old data as
|
|
18
|
+
* if it were current.
|
|
19
|
+
*/
|
|
20
|
+
export function useIsOffline(): boolean {
|
|
21
|
+
const [isOffline, setIsOffline] = useState(false);
|
|
22
|
+
useEffect(() => NetInfo.addEventListener((state) => setIsOffline(offline(state))), []);
|
|
23
|
+
return isOffline;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Was that failure the phone, or the server?
|
|
28
|
+
*
|
|
29
|
+
* React Native's `fetch` rejects with the same bare `TypeError` when the server is down and when
|
|
30
|
+
* the phone has no signal. Ask this before telling somebody the service is having trouble: only a
|
|
31
|
+
* `false` here means the service is to blame. A NetInfo that cannot answer counts as online, so
|
|
32
|
+
* the doubt goes to the message that has a retry beside it.
|
|
33
|
+
*/
|
|
34
|
+
export async function isDeviceOffline(): Promise<boolean> {
|
|
35
|
+
const state = await NetInfo.fetch().catch(() => null);
|
|
36
|
+
return state !== null && offline(state);
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gusnips-native-css: `buildNativeCss` from the command line, for an app's `start` script.
|
|
4
|
+
*
|
|
5
|
+
* Scoped like every bin in this repo: a bare name belongs to whoever registers it on npm, and
|
|
6
|
+
* `bunx` would download and run theirs.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { buildNativeCss, type PaletteEntry } from "../css.ts";
|
|
12
|
+
|
|
13
|
+
const HELP = `gusnips-native-css: compile global.css for React Native, before Metro starts.
|
|
14
|
+
|
|
15
|
+
Usage: gusnips-native-css [options]
|
|
16
|
+
|
|
17
|
+
--root <dir> the app's folder (default: the current folder)
|
|
18
|
+
--input <file> the Tailwind entry, from the app's folder (default: global.css)
|
|
19
|
+
--out <dir> where stylesheet.ts goes (default: src/generated)
|
|
20
|
+
--palette <file> a JSON file naming colours for code that cannot take a className:
|
|
21
|
+
{ "text": { "light": "text-ink", "dark": "dark:text-ink-dark" } }
|
|
22
|
+
writes colors.ts beside stylesheet.ts
|
|
23
|
+
--rem <px> pixels per rem (default: 16, as on the web)
|
|
24
|
+
-h, --help show this
|
|
25
|
+
|
|
26
|
+
Run it before Metro, in the same script: "start": "gusnips-native-css && react-native start"
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
async function main(): Promise<number> {
|
|
30
|
+
const { values } = parseArgs({
|
|
31
|
+
options: {
|
|
32
|
+
root: { type: "string" },
|
|
33
|
+
input: { type: "string" },
|
|
34
|
+
out: { type: "string" },
|
|
35
|
+
palette: { type: "string" },
|
|
36
|
+
rem: { type: "string" },
|
|
37
|
+
help: { type: "boolean", short: "h" },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
if (values.help) {
|
|
41
|
+
process.stdout.write(HELP);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const root = resolve(values.root ?? ".");
|
|
46
|
+
const result = await buildNativeCss({
|
|
47
|
+
root,
|
|
48
|
+
input: values.input,
|
|
49
|
+
outDir: values.out,
|
|
50
|
+
rem: values.rem === undefined ? undefined : Number(values.rem),
|
|
51
|
+
palette: values.palette === undefined ? undefined : await readPalette(resolve(values.palette)),
|
|
52
|
+
});
|
|
53
|
+
console.log(`native css: ${result.classes} classes → ${result.files.join(", ")}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The palette file is the app's own, but a typo in it should read as one, not as a crash. */
|
|
58
|
+
async function readPalette(file: string): Promise<Record<string, PaletteEntry>> {
|
|
59
|
+
const parsed: unknown = JSON.parse(await readFile(file, "utf-8"));
|
|
60
|
+
const palette: Record<string, PaletteEntry> = {};
|
|
61
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
62
|
+
throw new Error(`${file} must hold an object of colour names.`);
|
|
63
|
+
for (const [name, entry] of Object.entries(parsed)) {
|
|
64
|
+
const light: unknown = entry?.light;
|
|
65
|
+
const dark: unknown = entry?.dark;
|
|
66
|
+
if (typeof light !== "string" || typeof dark !== "string")
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${file}: "${name}" needs a "light" and a "dark" class, such as ` +
|
|
69
|
+
'{ "light": "text-ink", "dark": "dark:text-ink-dark" }.',
|
|
70
|
+
);
|
|
71
|
+
palette[name] = { light, dark };
|
|
72
|
+
}
|
|
73
|
+
return palette;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().then(
|
|
77
|
+
(code) => {
|
|
78
|
+
process.exitCode = code;
|
|
79
|
+
},
|
|
80
|
+
(error: unknown) => {
|
|
81
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
},
|
|
84
|
+
);
|
package/src/node/css.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind classes for a bare React Native app, built before Metro starts.
|
|
3
|
+
*
|
|
4
|
+
* NativeWind v5 and react-native-css resolve `className` at runtime against a compiled stylesheet.
|
|
5
|
+
* Their own way of compiling it is a Metro transformer that hands the CSS step to
|
|
6
|
+
* `@expo/metro-config`'s worker, which needs the `expo` package and its native modules. A bare app
|
|
7
|
+
* has neither, so this runs the same two Expo-free stages itself:
|
|
8
|
+
*
|
|
9
|
+
* 1. the Tailwind v4 CLI compiles the app's `global.css` to plain CSS, finding the classes in the
|
|
10
|
+
* app's own files (Tailwind v4 has no `--content` flag; a file outside the app folder needs an
|
|
11
|
+
* `@source` line in `global.css`);
|
|
12
|
+
* 2. react-native-css's compiler turns that CSS into the stylesheet its runtime reads.
|
|
13
|
+
*
|
|
14
|
+
* The output is a TypeScript module the app injects at launch with `StyleCollection.inject`.
|
|
15
|
+
* Editing a `className` hot-reloads as usual; only a change to the CSS or the tokens needs a rebuild,
|
|
16
|
+
* which is why the build runs in the `start`, `ios` and `android` scripts, before Metro.
|
|
17
|
+
*
|
|
18
|
+
* Both tools are resolved from the APP's folder, not from this package: the stylesheet has to be in
|
|
19
|
+
* the format of the react-native-css the bundle will run, and `global.css` imports `tailwindcss`
|
|
20
|
+
* from where the app installed it.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
27
|
+
import type { ReactNativeCssStyleSheet } from "react-native-css/compiler";
|
|
28
|
+
|
|
29
|
+
type Compiler = typeof import("react-native-css/compiler");
|
|
30
|
+
|
|
31
|
+
/** A colour code needs where it cannot take a `className`: one utility class per scheme. */
|
|
32
|
+
export interface PaletteEntry {
|
|
33
|
+
/** A class that paints this colour in light mode, such as `"bg-surface"`. */
|
|
34
|
+
light: string;
|
|
35
|
+
/** The same in dark mode, such as `"dark:bg-dark-surface"`. */
|
|
36
|
+
dark: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NativeCssOptions {
|
|
40
|
+
/** The app's folder. The other paths are read from it. Default: the current folder. */
|
|
41
|
+
root?: string;
|
|
42
|
+
/** The Tailwind entry. Default: `global.css`. */
|
|
43
|
+
input?: string;
|
|
44
|
+
/** Where `stylesheet.ts` (and `colors.ts`) are written. Default: `src/generated`. */
|
|
45
|
+
outDir?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How many pixels one `rem` is. Default: 16, the web's.
|
|
48
|
+
*
|
|
49
|
+
* react-native-css's own default is 14, React Native's root font size, and that silently shrinks
|
|
50
|
+
* every rem-based size by an eighth: body text from 14 to 12.25 px and `h-12` from 48 to 42 px,
|
|
51
|
+
* against tokens written for the web. A `:root { font-size }` in the CSS does not fix it
|
|
52
|
+
* reliably, so the number is passed to the compiler instead.
|
|
53
|
+
*/
|
|
54
|
+
rem?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Hex colours for what cannot take a `className`: an icon's `color`, the navigation theme, the
|
|
57
|
+
* status bar. Each name maps to a class in each scheme, and `colors.ts` gets the hex those classes
|
|
58
|
+
* compile to. Read from the stylesheet itself, so a change of tokens cannot restyle the classes
|
|
59
|
+
* and leave the icons in the old colours, which is what a hand-copied list of hex values did.
|
|
60
|
+
*/
|
|
61
|
+
palette?: Record<string, PaletteEntry>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NativeCssResult {
|
|
65
|
+
/** How many classes the stylesheet holds. */
|
|
66
|
+
classes: number;
|
|
67
|
+
/** The files written, as absolute paths. */
|
|
68
|
+
files: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ```ts
|
|
73
|
+
* await buildNativeCss(); // global.css → src/generated/stylesheet.ts
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export async function buildNativeCss(options: NativeCssOptions = {}): Promise<NativeCssResult> {
|
|
77
|
+
const root = resolve(options.root ?? ".");
|
|
78
|
+
const input = resolve(root, options.input ?? "global.css");
|
|
79
|
+
const outDir = resolve(root, options.outDir ?? "src/generated");
|
|
80
|
+
const rem = options.rem ?? 16;
|
|
81
|
+
if (!(rem > 0)) throw new Error(`buildNativeCss: rem must be a positive number, not ${rem}.`);
|
|
82
|
+
|
|
83
|
+
const fromApp = createRequire(join(root, "package.json"));
|
|
84
|
+
// Before Tailwind, so a missing react-native-css fails in a moment rather than after a compile.
|
|
85
|
+
const compiler = loadCompiler(fromApp);
|
|
86
|
+
const css = await runTailwind(fromApp, root, input);
|
|
87
|
+
const stylesheet = compiler.compile(css, { inlineRem: rem }).stylesheet();
|
|
88
|
+
dropUnmappable(stylesheet);
|
|
89
|
+
|
|
90
|
+
await mkdir(outDir, { recursive: true });
|
|
91
|
+
const banner = `// Generated by gusnips-native-css from ${relative(root, input)}. Do not edit: run it again.\n`;
|
|
92
|
+
const stylesheetFile = join(outDir, "stylesheet.ts");
|
|
93
|
+
await writeFile(
|
|
94
|
+
stylesheetFile,
|
|
95
|
+
`${banner}\nimport type { ReactNativeCssStyleSheet } from "react-native-css/compiler";\n\n` +
|
|
96
|
+
`export const stylesheet: ReactNativeCssStyleSheet = ${JSON.stringify(stylesheet)};\n`,
|
|
97
|
+
);
|
|
98
|
+
const files = [stylesheetFile];
|
|
99
|
+
|
|
100
|
+
const { palette } = options;
|
|
101
|
+
if (palette) {
|
|
102
|
+
const scheme = (mode: keyof PaletteEntry) =>
|
|
103
|
+
Object.fromEntries(
|
|
104
|
+
Object.entries(palette).map(([name, entry]) => [
|
|
105
|
+
name,
|
|
106
|
+
colorOf(stylesheet, entry[mode], root),
|
|
107
|
+
]),
|
|
108
|
+
);
|
|
109
|
+
const colors = { light: scheme("light"), dark: scheme("dark") };
|
|
110
|
+
const colorsFile = join(outDir, "colors.ts");
|
|
111
|
+
await writeFile(
|
|
112
|
+
colorsFile,
|
|
113
|
+
`${banner}\nexport const COLORS = ${JSON.stringify(colors, null, 2)};\n`,
|
|
114
|
+
);
|
|
115
|
+
files.push(colorsFile);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { classes: Array.isArray(stylesheet.s) ? stylesheet.s.length : 0, files };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runTailwind(fromApp: NodeRequire, root: string, input: string): Promise<string> {
|
|
122
|
+
const cli = tailwindCli(fromApp);
|
|
123
|
+
// Out of the app's tree, so building reads the app and writes only `outDir`. Kept when the
|
|
124
|
+
// compile fails, because the half-written CSS is the first thing worth reading.
|
|
125
|
+
const work = await mkdtemp(join(tmpdir(), "gusnips-native-css-"));
|
|
126
|
+
const out = join(work, "tailwind.css");
|
|
127
|
+
const run = spawnSync(process.execPath, [cli, "-i", input, "-o", out], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
// The compile takes seconds. A hung CLI with no deadline holds `bun run start` forever.
|
|
131
|
+
timeout: 120_000,
|
|
132
|
+
});
|
|
133
|
+
if (run.error !== undefined) throw new Error(`tailwindcss did not finish: ${run.error.message}`);
|
|
134
|
+
if (run.status !== 0)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`tailwindcss could not compile ${input}` +
|
|
137
|
+
`${run.stderr ? `:\n${run.stderr.slice(0, 1000)}` : ""}\n(work folder kept at ${work})`,
|
|
138
|
+
);
|
|
139
|
+
const css = await readFile(out, "utf-8");
|
|
140
|
+
await rm(work, { recursive: true, force: true });
|
|
141
|
+
return css;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tailwindCli(fromApp: NodeRequire): string {
|
|
145
|
+
let manifestPath: string;
|
|
146
|
+
try {
|
|
147
|
+
manifestPath = fromApp.resolve("@tailwindcss/cli/package.json");
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error(missing("@tailwindcss/cli"));
|
|
150
|
+
}
|
|
151
|
+
const manifest: { bin?: string | Record<string, string> } = fromApp(manifestPath);
|
|
152
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["tailwindcss"];
|
|
153
|
+
if (!bin) throw new Error(`@tailwindcss/cli at ${manifestPath} names no tailwindcss bin.`);
|
|
154
|
+
return join(dirname(manifestPath), bin);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The CommonJS build, on purpose: react-native-css's ES module build imports `debug` by a named
|
|
159
|
+
* export Node cannot see in a CommonJS package, so it does not load under plain Node (bun forgives
|
|
160
|
+
* it). `require` loads the build that works under both.
|
|
161
|
+
*/
|
|
162
|
+
function loadCompiler(fromApp: NodeRequire): Compiler {
|
|
163
|
+
let compiler: Compiler;
|
|
164
|
+
try {
|
|
165
|
+
compiler = fromApp("react-native-css/compiler");
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (isNotFound(error)) throw new Error(missing("react-native-css"));
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
refuseBrokenLightningcss(fromApp);
|
|
171
|
+
return compiler;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* lightningcss 1.30.2 breaks react-native-css's compiler. react-native-css checks for it and means
|
|
176
|
+
* to throw, but the check sits inside a `try` whose `catch` is empty, so the throw is swallowed and
|
|
177
|
+
* the build goes on. This asks the same copy its loader picks: the one under `@expo/metro-config`
|
|
178
|
+
* first (a peer of react-native-css, so bun installs it), then its own.
|
|
179
|
+
*
|
|
180
|
+
* That order is also why a devDependency on lightningcss does not fix it. Pin it with `overrides`
|
|
181
|
+
* in the root `package.json`, which reaches every copy.
|
|
182
|
+
*/
|
|
183
|
+
function refuseBrokenLightningcss(fromApp: NodeRequire): void {
|
|
184
|
+
const fromCss = createRequire(fromApp.resolve("react-native-css/package.json"));
|
|
185
|
+
const candidates: NodeRequire[] = [];
|
|
186
|
+
try {
|
|
187
|
+
candidates.push(createRequire(fromCss.resolve("@expo/metro-config/package.json")));
|
|
188
|
+
} catch {
|
|
189
|
+
// No expo config beside it: react-native-css loads its own lightningcss.
|
|
190
|
+
}
|
|
191
|
+
candidates.push(fromCss);
|
|
192
|
+
for (const from of candidates) {
|
|
193
|
+
let version: string;
|
|
194
|
+
try {
|
|
195
|
+
const manifest: { version: string } = from("lightningcss/package.json");
|
|
196
|
+
version = manifest.version;
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (version === "1.30.2")
|
|
201
|
+
throw new Error(
|
|
202
|
+
"lightningcss 1.30.2 breaks react-native-css's compiler. Pin it to 1.30.1 with " +
|
|
203
|
+
'"overrides": { "lightningcss": "1.30.1" } in the root package.json, or move to a newer one.',
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Tailwind finds class names by reading source as text, so a React Native style key such as
|
|
211
|
+
* `transform` (an Animated style needs one) becomes a candidate. `transform` IS a Tailwind utility,
|
|
212
|
+
* so it compiles, but its value cannot map to React Native and comes out `null`, which then fails
|
|
213
|
+
* the app's typecheck on the generated file. Nobody can use it as a class on a phone, so it is
|
|
214
|
+
* dropped here rather than banning a JavaScript word from every source file.
|
|
215
|
+
*/
|
|
216
|
+
function dropUnmappable(stylesheet: ReactNativeCssStyleSheet): void {
|
|
217
|
+
if (!Array.isArray(stylesheet.s)) return;
|
|
218
|
+
stylesheet.s = stylesheet.s.filter(
|
|
219
|
+
(entry) => !(Array.isArray(entry) && entry[0] === "transform"),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function colorOf(stylesheet: ReactNativeCssStyleSheet, className: string, root: string): string {
|
|
224
|
+
const entry = Array.isArray(stylesheet.s)
|
|
225
|
+
? stylesheet.s.find((each) => Array.isArray(each) && each[0] === className)
|
|
226
|
+
: undefined;
|
|
227
|
+
const hex = entry ? JSON.stringify(entry[1]).match(/#[0-9a-fA-F]{3,8}\b/)?.[0] : undefined;
|
|
228
|
+
if (hex === undefined)
|
|
229
|
+
throw new Error(
|
|
230
|
+
`No colour for "${className}" in the compiled stylesheet. Tailwind writes only the classes ` +
|
|
231
|
+
`it finds in the app's files, so put "${className}" in a file under ${root} (a list of ` +
|
|
232
|
+
"the palette's classes beside the code that reads the colours keeps them found).",
|
|
233
|
+
);
|
|
234
|
+
return hex;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function missing(name: string): string {
|
|
238
|
+
return (
|
|
239
|
+
`${name} is not installed in the app. gusnips-native-css reads it from the app's own ` +
|
|
240
|
+
"packages: bun add react-native-css && bun add -d @tailwindcss/cli tailwindcss"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isNotFound(error: unknown): boolean {
|
|
245
|
+
return error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND";
|
|
246
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { nativeMetroConfig, type ResolutionContext } from "./metro.ts";
|
|
6
|
+
|
|
7
|
+
// This package's own folder stands in for the app: react and react-native-css are its
|
|
8
|
+
// devDependencies, so they resolve from here the way they resolve from an app.
|
|
9
|
+
const app = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
const fromApp = createRequire(join(app, "package.json"));
|
|
11
|
+
|
|
12
|
+
const context: ResolutionContext = {
|
|
13
|
+
resolveRequest: (_context, moduleName) => ({ type: `metro:${moduleName}` }),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("nativeMetroConfig", () => {
|
|
17
|
+
const config = nativeMetroConfig(
|
|
18
|
+
{ projectRoot: app, watchFolders: [app], resolver: { nodeModulesPaths: [] } },
|
|
19
|
+
{ monorepoRoot: "/repo" },
|
|
20
|
+
);
|
|
21
|
+
const resolve = (moduleName: string) =>
|
|
22
|
+
config.resolver?.resolveRequest?.(context, moduleName, "ios");
|
|
23
|
+
|
|
24
|
+
it("sends react and its entry points to the app's own copy", () => {
|
|
25
|
+
expect(resolve("react")).toEqual({ type: "sourceFile", filePath: fromApp.resolve("react") });
|
|
26
|
+
expect(resolve("react/jsx-runtime")).toEqual({
|
|
27
|
+
type: "sourceFile",
|
|
28
|
+
filePath: fromApp.resolve("react/jsx-runtime"),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("leaves packages that only start with the word to Metro", () => {
|
|
33
|
+
for (const name of ["react-native", "react-dom", "react-i18next", "reactive"])
|
|
34
|
+
expect(resolve(name)).toEqual({ type: `metro:${name}` });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("maps react-native-css's trap module to its empty override", () => {
|
|
38
|
+
const override = join(dirname(fromApp.resolve("react-native-css/metro")), "override.js");
|
|
39
|
+
expect(resolve("react-native-css-metro-override")).toEqual({
|
|
40
|
+
type: "sourceFile",
|
|
41
|
+
filePath: override,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("points Metro at the monorepo without dropping what the config had", () => {
|
|
46
|
+
expect(config.watchFolders).toEqual([app, "/repo"]);
|
|
47
|
+
expect(config.resolver?.nodeModulesPaths).toEqual([
|
|
48
|
+
join(app, "node_modules"),
|
|
49
|
+
"/repo/node_modules",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("hands what it does not decide to a resolver already on the config", () => {
|
|
54
|
+
const chained = nativeMetroConfig({
|
|
55
|
+
projectRoot: app,
|
|
56
|
+
resolver: { resolveRequest: (_context, moduleName) => ({ type: `own:${moduleName}` }) },
|
|
57
|
+
});
|
|
58
|
+
expect(chained.resolver?.resolveRequest?.(context, "lodash", null)).toEqual({
|
|
59
|
+
type: "own:lodash",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("refuses a config with no projectRoot", () => {
|
|
64
|
+
expect(() => nativeMetroConfig({})).toThrow(/getDefaultConfig/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Metro config a bare React Native app needs inside a bun monorepo.
|
|
3
|
+
*
|
|
4
|
+
* Three things, each learned by a crash:
|
|
5
|
+
*
|
|
6
|
+
* - **Metro does not find workspaces on its own.** `watchFolders` and `nodeModulesPaths` point it at
|
|
7
|
+
* the monorepo, so a workspace package that ships TypeScript source resolves like any other.
|
|
8
|
+
* (Symlinks and package `exports` are on by default since Metro 0.79 and 0.82.)
|
|
9
|
+
* - **One React, the app's.** bun's isolated store keeps a spare `react` at its root, and a package
|
|
10
|
+
* that forgets to declare react as a peer resolves `react` by walking up to THAT copy. Two Reacts
|
|
11
|
+
* in one bundle: the app's own screens render, and that package's hooks crash with "Cannot read
|
|
12
|
+
* property 'useMemo' of null". So every `react` and `react/*` import goes to the copy the app
|
|
13
|
+
* itself resolves.
|
|
14
|
+
* - **react-native-css's override module.** `react-native-css/native` re-exports a module named
|
|
15
|
+
* `react-native-css-metro-override` that throws on import unless Metro maps it to the package's
|
|
16
|
+
* own empty `override.js`. Without the mapping every styled component dies at launch with "Cannot
|
|
17
|
+
* read property 'useCssElement' of undefined". `withReactNativeCSS` does map it, and also swaps
|
|
18
|
+
* Metro's transformer for one that requires `@expo/metro-config`, which a bare app does not have.
|
|
19
|
+
* Only the mapping is taken here; the CSS itself is built ahead of time by `gusnips-native-css`.
|
|
20
|
+
* Skipped when the app does not use react-native-css.
|
|
21
|
+
*
|
|
22
|
+
* Metro's own types are not imported: this package does not depend on metro, and the few fields read
|
|
23
|
+
* here are stated below.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** What a resolver hands back to Metro. Only the `sourceFile` form is built here. */
|
|
30
|
+
export type Resolution = { type: "sourceFile"; filePath: string } | { type: string };
|
|
31
|
+
|
|
32
|
+
/** Metro's `CustomResolver`, narrowed to the part this file reads. */
|
|
33
|
+
export type ResolveRequest = (
|
|
34
|
+
context: ResolutionContext,
|
|
35
|
+
moduleName: string,
|
|
36
|
+
platform: string | null,
|
|
37
|
+
) => Resolution;
|
|
38
|
+
|
|
39
|
+
export interface ResolutionContext {
|
|
40
|
+
/** Metro's default resolver, for everything this one does not decide. */
|
|
41
|
+
readonly resolveRequest: ResolveRequest;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MetroConfigInput {
|
|
45
|
+
readonly projectRoot?: string;
|
|
46
|
+
readonly watchFolders?: readonly string[];
|
|
47
|
+
readonly resolver?: {
|
|
48
|
+
readonly nodeModulesPaths?: readonly string[];
|
|
49
|
+
readonly resolveRequest?: ResolveRequest | null;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NativeMetroOptions {
|
|
54
|
+
/**
|
|
55
|
+
* The monorepo's root folder, where the root `package.json` and `node_modules` are. Leave it out
|
|
56
|
+
* for an app that is not in a monorepo.
|
|
57
|
+
*/
|
|
58
|
+
monorepoRoot?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wrap the config `@react-native/metro-config` gives you.
|
|
63
|
+
*
|
|
64
|
+
* ```js
|
|
65
|
+
* // metro.config.js
|
|
66
|
+
* const { getDefaultConfig } = require("@react-native/metro-config");
|
|
67
|
+
* const { nativeMetroConfig } = require("@gusnips/native/metro");
|
|
68
|
+
*
|
|
69
|
+
* module.exports = nativeMetroConfig(getDefaultConfig(__dirname), {
|
|
70
|
+
* monorepoRoot: require("node:path").resolve(__dirname, "../.."),
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* A resolver already on the config still runs for everything this one does not decide.
|
|
75
|
+
*/
|
|
76
|
+
export function nativeMetroConfig<C extends MetroConfigInput>(
|
|
77
|
+
config: C,
|
|
78
|
+
{ monorepoRoot }: NativeMetroOptions = {},
|
|
79
|
+
) {
|
|
80
|
+
const { projectRoot } = config;
|
|
81
|
+
if (!projectRoot)
|
|
82
|
+
throw new Error(
|
|
83
|
+
"nativeMetroConfig: the config has no projectRoot. Pass it the config " +
|
|
84
|
+
"getDefaultConfig(__dirname) returns, which always sets one.",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const fromApp = createRequire(join(projectRoot, "package.json"));
|
|
88
|
+
const cssOverride = reactNativeCssOverride(fromApp);
|
|
89
|
+
const next = config.resolver?.resolveRequest ?? null;
|
|
90
|
+
|
|
91
|
+
const resolveRequest: ResolveRequest = (context, moduleName, platform) => {
|
|
92
|
+
if (cssOverride && moduleName.includes("react-native-css-metro-override"))
|
|
93
|
+
return { type: "sourceFile", filePath: cssOverride };
|
|
94
|
+
if (isReact(moduleName)) return { type: "sourceFile", filePath: fromApp.resolve(moduleName) };
|
|
95
|
+
return (next ?? context.resolveRequest)(context, moduleName, platform);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const extraRoots = monorepoRoot ? [monorepoRoot] : [];
|
|
99
|
+
return {
|
|
100
|
+
...config,
|
|
101
|
+
watchFolders: unique([...(config.watchFolders ?? []), ...extraRoots]),
|
|
102
|
+
resolver: {
|
|
103
|
+
...config.resolver,
|
|
104
|
+
nodeModulesPaths: unique([
|
|
105
|
+
...[projectRoot, ...extraRoots].map((root) => join(root, "node_modules")),
|
|
106
|
+
...(config.resolver?.nodeModulesPaths ?? []),
|
|
107
|
+
]),
|
|
108
|
+
resolveRequest,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `react` and its own entry points, and nothing that merely starts with the word: `react-native`,
|
|
115
|
+
* `react-dom` and `react-i18next` are packages of their own and resolve the normal way.
|
|
116
|
+
*
|
|
117
|
+
* ponytail: only react is forced to one copy, because only react has been seen twice. If another
|
|
118
|
+
* package turns up twice in a bundle (react-native itself is the likely next), add it here.
|
|
119
|
+
*/
|
|
120
|
+
function isReact(moduleName: string): boolean {
|
|
121
|
+
return moduleName === "react" || moduleName.startsWith("react/");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The app's react-native-css `override.js`, or null when the app has no react-native-css. */
|
|
125
|
+
function reactNativeCssOverride(fromApp: NodeRequire): string | null {
|
|
126
|
+
let metroEntry: string;
|
|
127
|
+
try {
|
|
128
|
+
metroEntry = fromApp.resolve("react-native-css/metro");
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const override = join(dirname(metroEntry), "override.js");
|
|
133
|
+
if (!existsSync(override))
|
|
134
|
+
throw new Error(
|
|
135
|
+
`nativeMetroConfig: react-native-css is installed, but ${override} is not there. This ` +
|
|
136
|
+
"version keeps its override module somewhere else; @gusnips/native needs updating for it.",
|
|
137
|
+
);
|
|
138
|
+
return override;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function unique(paths: readonly string[]): string[] {
|
|
142
|
+
return [...new Set(paths)];
|
|
143
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Query on a phone: "focused" means the app is in the foreground, and "online" means NetInfo
|
|
3
|
+
* says so. React Query's own defaults read the browser's window and `navigator.onLine`, and a
|
|
4
|
+
* phone has neither.
|
|
5
|
+
*
|
|
6
|
+
* What this changes, so nobody expects more of it: a query's retries wait while the app is in the
|
|
7
|
+
* background or the phone is offline, and resume after; stale queries run again when the connection
|
|
8
|
+
* comes back (`refetchOnReconnect`, which `queryDefaults` turns on). It does NOT refetch when the app
|
|
9
|
+
* returns to the foreground, because `queryDefaults` leaves `refetchOnWindowFocus` off on purpose.
|
|
10
|
+
* The donor's comment said "refetch on app foreground" over a client where that option was off, so
|
|
11
|
+
* a screen still mounted from yesterday shows yesterday's data until something else refetches it.
|
|
12
|
+
* Pass `queryDefaults({ refetchOnWindowFocus: true })` if returning to the app should refresh.
|
|
13
|
+
*/
|
|
14
|
+
import NetInfo from "@react-native-community/netinfo";
|
|
15
|
+
import { focusManager, onlineManager } from "@tanstack/react-query";
|
|
16
|
+
import { AppState } from "react-native";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Call once, before the first query runs, beside where you build the `QueryClient`.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* installQueryManagers();
|
|
23
|
+
* export const queryClient = new QueryClient({ defaultOptions: queryDefaults() });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The managers are React Query's own singletons, so a second call replaces the first rather than
|
|
27
|
+
* adding a second listener.
|
|
28
|
+
*/
|
|
29
|
+
export function installQueryManagers(): void {
|
|
30
|
+
focusManager.setEventListener((setFocused) => {
|
|
31
|
+
const subscription = AppState.addEventListener("change", (state) =>
|
|
32
|
+
setFocused(state === "active"),
|
|
33
|
+
);
|
|
34
|
+
return () => subscription.remove();
|
|
35
|
+
});
|
|
36
|
+
// `!== false`, not `=== true`: NetInfo says `null` until it knows, and paused queries at every
|
|
37
|
+
// launch would be the price of reading that as offline.
|
|
38
|
+
onlineManager.setEventListener((setOnline) =>
|
|
39
|
+
NetInfo.addEventListener((state) => setOnline(state.isConnected !== false)),
|
|
40
|
+
);
|
|
41
|
+
}
|