@pylonsync/react 0.8.3 → 0.9.1

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/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { defineRoute } from "@pylonsync/sdk";
2
- export type { RouteMode, AppManifest } from "@pylonsync/sdk";
1
+ export { defineRoute } from "@pylonsync/sdk/route";
2
+ export type { RouteMode } from "@pylonsync/sdk/route";
3
+ export type { AppManifest } from "@pylonsync/sdk";
3
4
  export { Link } from "./Link";
4
5
  export type { LinkProps } from "./Link";
5
6
  export { dynamic } from "./dynamic";
@@ -3,6 +3,15 @@ export type { ResolvedSession };
3
3
  export interface UseSessionReturn {
4
4
  /** Server-resolved session. `userId=null` means anonymous. */
5
5
  session: ResolvedSession;
6
+ /**
7
+ * True once `/api/auth/me` has answered during this engine run. Until
8
+ * then `session` is the empty placeholder: `userId` is null because the
9
+ * answer has not arrived, not because the user is anonymous. Route
10
+ * guards must not send a user to sign-in while this is false. Stays
11
+ * false on an offline start; the persisted token still authenticates
12
+ * the replica and the next successful pull resolves it.
13
+ */
14
+ resolved: boolean;
6
15
  /** Convenience accessors. */
7
16
  userId: string | null;
8
17
  tenantId: string | null;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.8.3",
6
+ "version": "0.9.1",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/sdk": "0.8.3",
18
- "@pylonsync/sync": "0.8.3"
17
+ "@pylonsync/sdk": "0.9.1",
18
+ "@pylonsync/sync": "0.9.1"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
- export { defineRoute } from "@pylonsync/sdk";
2
- export type { RouteMode, AppManifest } from "@pylonsync/sdk";
1
+ // `defineRoute` comes from the sdk's `route` entry, not its root. The root
2
+ // module contains the filesystem-backed discovery helpers, which reference
3
+ // `import.meta` and Node builtins that Metro cannot bundle. Keep every
4
+ // runtime import in this package off the sdk root so React Native apps
5
+ // can import `@pylonsync/react` (and `@pylonsync/react-native`).
6
+ export { defineRoute } from "@pylonsync/sdk/route";
7
+ export type { RouteMode } from "@pylonsync/sdk/route";
8
+ export type { AppManifest } from "@pylonsync/sdk";
3
9
 
4
10
  // SSR primitives — Next.js-style <Link> and <Image>. Both render
5
11
  // progressively (work without JS) and enhance on the client.
@@ -0,0 +1,95 @@
1
+ // The React Native entry points must bundle under Metro. Metro resolves
2
+ // every static and dynamic import at build time and has no `import.meta`,
3
+ // so one Node-only module anywhere in the runtime import graph breaks
4
+ // every native build. This walks the graph from the two client entries
5
+ // through the workspace packages and fails on the first offender.
6
+ //
7
+ // It caught the sdk root (`discoverAppRoutes` uses `import.meta.url` and
8
+ // `node:module`) being pulled in through a `defineRoute` re-export.
9
+
10
+ import { expect, test } from "bun:test";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { dirname, join, relative, resolve } from "node:path";
13
+
14
+ const PACKAGES = resolve(import.meta.dir, "..", "..");
15
+ const ENTRIES = [
16
+ join(PACKAGES, "react", "src", "index.ts"),
17
+ join(PACKAGES, "react-native", "src", "index.ts"),
18
+ join(PACKAGES, "plugins", "revenuecat", "src", "client.ts"),
19
+ ];
20
+ const NODE_BUILTINS = new Set([
21
+ "assert", "buffer", "child_process", "crypto", "events", "fs", "http",
22
+ "https", "module", "net", "os", "path", "stream", "url", "util",
23
+ "worker_threads", "zlib",
24
+ ]);
25
+
26
+ function packageDir(name: string): string {
27
+ const plugin = join(PACKAGES, "plugins", name);
28
+ return existsSync(plugin) ? plugin : join(PACKAGES, name);
29
+ }
30
+
31
+ /** Resolve an import specifier to a workspace source file, or null for
32
+ * an external dependency (react, react-native, async-storage, ...). */
33
+ function resolveImport(spec: string, fromFile: string): string | null {
34
+ if (spec.startsWith(".")) {
35
+ const base = resolve(dirname(fromFile), spec);
36
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) {
37
+ if (existsSync(candidate) && !candidate.endsWith("/")) {
38
+ try {
39
+ readFileSync(candidate);
40
+ return candidate;
41
+ } catch {
42
+ /* directory */
43
+ }
44
+ }
45
+ }
46
+ throw new Error(`cannot resolve ${spec} from ${fromFile}`);
47
+ }
48
+ const m = /^@pylonsync\/([^/]+)(?:\/(.+))?$/.exec(spec);
49
+ if (!m) return null;
50
+ const dir = packageDir(m[1]);
51
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
52
+ main?: string;
53
+ exports?: Record<string, string | { default?: string }>;
54
+ };
55
+ const key = m[2] ? `./${m[2]}` : ".";
56
+ const entry = pkg.exports?.[key];
57
+ const target =
58
+ typeof entry === "string" ? entry : entry?.default ?? (key === "." ? pkg.main : undefined);
59
+ if (!target) throw new Error(`${spec}: no "${key}" export in ${dir}/package.json`);
60
+ return resolve(dir, target);
61
+ }
62
+
63
+ test("the React Native entry points import no Node-only code", () => {
64
+ const seen = new Set<string>();
65
+ const queue = [...ENTRIES];
66
+ const offenders: string[] = [];
67
+ const rel = (f: string) => relative(PACKAGES, f);
68
+
69
+ while (queue.length > 0) {
70
+ const file = queue.pop()!;
71
+ if (seen.has(file)) continue;
72
+ seen.add(file);
73
+ const source = readFileSync(file, "utf8");
74
+ // Erase types first so `import type` / `export type ... from` do not
75
+ // count — Babel drops them before Metro ever sees the specifier.
76
+ const loader = file.endsWith(".tsx") ? "tsx" : "ts";
77
+ const js = new Bun.Transpiler({ loader }).transformSync(source);
78
+ if (/\bimport\.meta\b/.test(js)) offenders.push(`${rel(file)} uses import.meta`);
79
+ for (const imp of new Bun.Transpiler({ loader: "js" }).scanImports(js)) {
80
+ const spec = imp.path;
81
+ if (spec.startsWith("node:") || NODE_BUILTINS.has(spec)) {
82
+ offenders.push(`${rel(file)} imports ${spec}`);
83
+ continue;
84
+ }
85
+ const next = resolveImport(spec, file);
86
+ if (next) queue.push(next);
87
+ }
88
+ }
89
+
90
+ expect(offenders).toEqual([]);
91
+ // Sanity: the walk actually crossed into @pylonsync/sync and the sdk.
92
+ expect([...seen].some((f) => f.includes("/sync/src/"))).toBe(true);
93
+ expect([...seen].some((f) => f.endsWith("/sdk/src/route.ts"))).toBe(true);
94
+ expect([...seen].some((f) => f.endsWith("/sdk/src/index.ts"))).toBe(false);
95
+ });
package/src/useRouter.ts CHANGED
@@ -17,7 +17,14 @@ import { use, useEffect, useMemo, useRef, useState, useSyncExternalStore } from
17
17
  // `Window.__pylon` is globally augmented in ./Link (same package, ambient).
18
18
 
19
19
  function subscribe(onChange: () => void): () => void {
20
- if (typeof window === "undefined") return () => {};
20
+ // React Native has a `window` global with no event API; treat it
21
+ // like the server and never subscribe.
22
+ if (
23
+ typeof window === "undefined" ||
24
+ typeof window.addEventListener !== "function"
25
+ ) {
26
+ return () => {};
27
+ }
21
28
  window.addEventListener("popstate", onChange);
22
29
  window.addEventListener("pylon:navigation", onChange);
23
30
  return () => {
@@ -0,0 +1,47 @@
1
+ // React Native defines `window` as an alias of the global object with no
2
+ // `addEventListener`. `useSession` used to subscribe to `storage` events
3
+ // whenever `window` existed, which threw inside its mount effect and
4
+ // crashed every native app on startup. This mounts the hook under that
5
+ // shape of `window` and checks the session still resolves.
6
+
7
+ import { afterEach, expect, test } from "bun:test";
8
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
9
+ import React from "react";
10
+ import type { SyncEngine } from "@pylonsync/sync";
11
+ import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
12
+
13
+ import { useSession } from "./useSession";
14
+
15
+ let env: TestEnv | null = null;
16
+
17
+ afterEach(async () => {
18
+ cleanup();
19
+ if (env) {
20
+ await env.dispose();
21
+ env = null;
22
+ }
23
+ });
24
+
25
+ function Probe({ engine }: { engine: SyncEngine }) {
26
+ const s = useSession(engine);
27
+ return <span data-testid="s">{s.resolved ? (s.userId ?? "anonymous") : "pending"}</span>;
28
+ }
29
+
30
+ test("mounts where window has no event API and reports resolution", async () => {
31
+ env = await createTestEnv({ transport: "poll" });
32
+ env.signIn({ userId: "u1" });
33
+ const engine = env.engine as unknown as SyncEngine;
34
+
35
+ const original = window.addEventListener;
36
+ (window as unknown as { addEventListener: unknown }).addEventListener = undefined;
37
+ try {
38
+ render(<Probe engine={engine} />);
39
+ expect(screen.getByTestId("s").textContent).toBe("pending");
40
+ await act(async () => {
41
+ await engine.start();
42
+ });
43
+ await waitFor(() => expect(screen.getByTestId("s").textContent).toBe("u1"));
44
+ } finally {
45
+ window.addEventListener = original;
46
+ }
47
+ });
package/src/useSession.ts CHANGED
@@ -8,6 +8,15 @@ export type { ResolvedSession };
8
8
  export interface UseSessionReturn {
9
9
  /** Server-resolved session. `userId=null` means anonymous. */
10
10
  session: ResolvedSession;
11
+ /**
12
+ * True once `/api/auth/me` has answered during this engine run. Until
13
+ * then `session` is the empty placeholder: `userId` is null because the
14
+ * answer has not arrived, not because the user is anonymous. Route
15
+ * guards must not send a user to sign-in while this is false. Stays
16
+ * false on an offline start; the persisted token still authenticates
17
+ * the replica and the next successful pull resolves it.
18
+ */
19
+ resolved: boolean;
11
20
  /** Convenience accessors. */
12
21
  userId: string | null;
13
22
  tenantId: string | null;
@@ -50,6 +59,11 @@ export function useSession(sync: SyncEngine): UseSessionReturn {
50
59
  () => sync.resolvedSession(),
51
60
  () => sync.resolvedSession(),
52
61
  );
62
+ const resolved = useSyncExternalStore(
63
+ (cb) => sync.store.subscribe(cb),
64
+ () => sync.sessionResolved(),
65
+ () => false,
66
+ );
53
67
 
54
68
  // Watch the localStorage token key. If another tab signs in/out, or the
55
69
  // app writes a new token without going through `notifySessionChanged`,
@@ -69,7 +83,13 @@ export function useSession(sync: SyncEngine): UseSessionReturn {
69
83
  }
70
84
  }
71
85
  };
72
- if (typeof window !== "undefined") {
86
+ // React Native defines `window` as an alias of the global object,
87
+ // without `addEventListener`. Only browsers deliver `storage` events,
88
+ // so subscribe only where the listener API exists.
89
+ if (
90
+ typeof window !== "undefined" &&
91
+ typeof window.addEventListener === "function"
92
+ ) {
73
93
  window.addEventListener("storage", onStorage);
74
94
  return () => window.removeEventListener("storage", onStorage);
75
95
  }
@@ -78,6 +98,7 @@ export function useSession(sync: SyncEngine): UseSessionReturn {
78
98
 
79
99
  return {
80
100
  session,
101
+ resolved,
81
102
  userId: session.userId,
82
103
  tenantId: session.tenantId,
83
104
  isAdmin: session.isAdmin,