@multiplatform.one/core 7.10.0 → 7.15.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 (39) hide show
  1. package/README.md +25 -0
  2. package/package.json +16 -16
  3. package/src/app/CreateApp.frappe.spec.tsx +109 -0
  4. package/src/app/CreateApp.origin.spec.tsx +182 -0
  5. package/src/app/CreateApp.tsx +67 -16
  6. package/src/app/CreateRootLayout.hydration.spec.tsx +97 -0
  7. package/src/app/CreateRootLayout.schemeClass.spec.tsx +165 -0
  8. package/src/app/CreateRootLayout.web.spec.tsx +12 -1
  9. package/src/app/CreateRootLayout.web.tsx +7 -10
  10. package/src/app/TanstackProvider.devtools.spec.tsx +77 -0
  11. package/src/app/TanstackProvider.tsx +3 -2
  12. package/src/app/deepLinkPath.spec.ts +23 -0
  13. package/src/app/deepLinkPath.ts +16 -3
  14. package/src/app/frappeSocketPort.spec.ts +65 -0
  15. package/src/app/frappeSocketPort.ts +28 -0
  16. package/src/app/frappeSyncOptions.ts +20 -0
  17. package/src/app/index.ts +1 -0
  18. package/src/app/oneFrappeNavigation.ts +11 -0
  19. package/src/app/rootLayoutShared.ts +2 -0
  20. package/src/app/runtimePublicConfigMiddleware.spec.ts +111 -0
  21. package/src/app/runtimePublicConfigMiddleware.ts +50 -0
  22. package/types/app/CreateApp.d.ts +37 -10
  23. package/types/app/CreateApp.d.ts.map +1 -1
  24. package/types/app/CreateRootLayout.web.d.ts.map +1 -1
  25. package/types/app/TanstackProvider.d.ts.map +1 -1
  26. package/types/app/deepLinkPath.d.ts +4 -0
  27. package/types/app/deepLinkPath.d.ts.map +1 -1
  28. package/types/app/frappeSocketPort.d.ts +12 -0
  29. package/types/app/frappeSocketPort.d.ts.map +1 -0
  30. package/types/app/frappeSyncOptions.d.ts +13 -0
  31. package/types/app/frappeSyncOptions.d.ts.map +1 -0
  32. package/types/app/index.d.ts +1 -0
  33. package/types/app/index.d.ts.map +1 -1
  34. package/types/app/oneFrappeNavigation.d.ts +4 -0
  35. package/types/app/oneFrappeNavigation.d.ts.map +1 -0
  36. package/types/app/rootLayoutShared.d.ts +2 -0
  37. package/types/app/rootLayoutShared.d.ts.map +1 -1
  38. package/types/app/runtimePublicConfigMiddleware.d.ts +24 -0
  39. package/types/app/runtimePublicConfigMiddleware.d.ts.map +1 -0
package/README.md CHANGED
@@ -52,6 +52,31 @@ Pass `true` to a provider slot to read all settings from env config, or an
52
52
  object (`TanstackConfig`, `KeycloakConfig`, `FrappeConfig`, `Web3AppConfig`)
53
53
  to configure explicitly. Disabled slots add zero bundle weight.
54
54
 
55
+ ### Frappe catch-up and the `live` app
56
+
57
+ After a reconnect, focus or `online`, the Frappe provider catches up on what it
58
+ missed. The default, CDC, calls `live.live.api.backfill`, which exists only
59
+ when the bench carries the `live` Frappe app (`apps/frappe/apps/live`). A bench
60
+ without it answers 417 ("App live is not installed") on every catch-up.
61
+
62
+ Such a deployment sets `FRAPPE_CATCH_UP_STRATEGY=snapshot` in the web
63
+ container's environment. Catch-up then re-reads each mounted collection through
64
+ `/api/resource` and makes no `live.*` request, for signed-in users and visitors
65
+ alike. The key is public config, so the SSR document publishes it per request:
66
+ one image serves a bench with `live` and one without, no rebuild. Unset, or
67
+ `cdc`, keeps CDC. Writes through the SDK (`createDoc`, `updateDoc`,
68
+ `deleteDoc`) still call `live`, so this makes a bench without it work for
69
+ reads, not writes. Snapshot mode also refuses progressive subscriptions, so a
70
+ screen built on `useFrappeInfiniteList` fails under it.
71
+
72
+ An app can fix the choice in code instead, through `frappe.syncOptions`, which
73
+ `createApp` hands to `FrappeProvider` as is. A `catchUpStrategy` set there wins
74
+ over the env.
75
+
76
+ ```tsx
77
+ createApp({ frappe: { syncOptions: { catchUpStrategy: "snapshot" } } });
78
+ ```
79
+
55
80
  ## License
56
81
 
57
82
  Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/core",
3
- "version": "7.10.0",
3
+ "version": "7.15.0",
4
4
  "description": "Core framework shell, provider composition, and layout factories for multiplatform.one",
5
5
  "keywords": [
6
6
  "app",
@@ -11,7 +11,7 @@
11
11
  "author": "BitSpur",
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
14
+ "url": "git+https://git.corp.bitspur.com/multiplatform.one/mpo.git",
15
15
  "directory": "public/core"
16
16
  },
17
17
  "source": "src/index.ts",
@@ -41,19 +41,21 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
+ "@multiplatform.one/i18n": "7.15.0",
45
+ "@multiplatform.one/logger": "7.15.0",
46
+ "@multiplatform.one/platform": "7.15.0",
47
+ "@multiplatform.one/store": "7.15.0",
48
+ "@multiplatform.one/theme": "7.15.0",
44
49
  "@tanstack/devtools-event-client": "^0.4.3",
45
50
  "@tanstack/devtools-utils": "^0.4.0",
46
51
  "@tanstack/pacer-devtools": "1.3.0",
47
52
  "@tanstack/react-devtools": "^0.10.2",
48
53
  "@tanstack/react-form-devtools": "0.2.22",
49
- "react-cookie": "^8.1.2",
50
- "@multiplatform.one/i18n": "7.10.0",
51
- "@multiplatform.one/theme": "7.10.0",
52
- "@multiplatform.one/store": "7.10.0",
53
- "@multiplatform.one/platform": "7.10.0",
54
- "@multiplatform.one/logger": "7.10.0"
54
+ "react-cookie": "^8.1.2"
55
55
  },
56
56
  "devDependencies": {
57
+ "@multiplatform.one/frappe": "7.15.0",
58
+ "@multiplatform.one/web3": "7.15.0",
57
59
  "@sentry/react": "^10.51.0",
58
60
  "@tanstack/react-query": "^5.100.7",
59
61
  "@tanstack/react-query-devtools": "^5.100.7",
@@ -67,11 +69,13 @@
67
69
  "react-native-safe-area-context": "~5.7.0",
68
70
  "tamagui": "2.7.6",
69
71
  "typescript": "~5.9.3",
70
- "vitest": "^4.1.5",
71
- "@multiplatform.one/frappe": "7.10.0",
72
- "@multiplatform.one/web3": "7.10.0"
72
+ "vitest": "^4.1.5"
73
73
  },
74
74
  "peerDependencies": {
75
+ "@multiplatform.one/frappe": "^7.15.0",
76
+ "@multiplatform.one/frappe-ui": "^7.15.0",
77
+ "@multiplatform.one/keycloak": "^7.15.0",
78
+ "@multiplatform.one/web3": "^7.15.0",
75
79
  "@sentry/react": "^9.0.0",
76
80
  "@tanstack/react-query": "^5.0.0",
77
81
  "@tanstack/react-query-devtools": "^5.0.0",
@@ -84,11 +88,7 @@
84
88
  "react-native": ">=0.83.0",
85
89
  "react-native-gesture-handler": ">=2.0.0",
86
90
  "react-native-safe-area-context": ">=4.0.0",
87
- "tamagui": "^2.0.0-rc",
88
- "@multiplatform.one/frappe": "^7.10.0",
89
- "@multiplatform.one/frappe-ui": "^7.10.0",
90
- "@multiplatform.one/web3": "^7.10.0",
91
- "@multiplatform.one/keycloak": "^7.10.0"
91
+ "tamagui": "^2.0.0-rc"
92
92
  },
93
93
  "peerDependenciesMeta": {
94
94
  "@multiplatform.one/web3": {
@@ -0,0 +1,109 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ReactNode } from "react";
4
+ import type { SyncOptions } from "@multiplatform.one/frappe";
5
+ import type { FrappeNavigation } from "@multiplatform.one/frappe-ui";
6
+ import { createApp, type FrappeConfig } from "./CreateApp";
7
+
8
+ const { env, provided, router } = vi.hoisted(() => ({
9
+ env: {} as Record<string, string | undefined>,
10
+ provided: { navigation: undefined as FrappeNavigation | undefined },
11
+ router: { pushed: [] as string[], pathname: "/moves/a1b2c3" },
12
+ }));
13
+
14
+ vi.mock("@multiplatform.one/platform", () => ({
15
+ config: { get: (key: string) => env[key] },
16
+ isDev: false,
17
+ isWeb: true,
18
+ }));
19
+ vi.mock("one", () => ({
20
+ usePathname: () => router.pathname,
21
+ useRouter: () => ({ push: (href: string) => router.pushed.push(href) }),
22
+ }));
23
+ vi.mock("@multiplatform.one/theme", () => ({
24
+ ThemeProvider: ({ children }: { children: ReactNode }) => children,
25
+ }));
26
+ vi.mock("@multiplatform.one/frappe-ui", () => ({
27
+ FrappeProvider: ({
28
+ children,
29
+ syncOptions,
30
+ navigation,
31
+ }: {
32
+ children: ReactNode;
33
+ syncOptions?: SyncOptions;
34
+ navigation?: FrappeNavigation;
35
+ }) => {
36
+ provided.navigation = navigation;
37
+ return (
38
+ <>
39
+ <output>{JSON.stringify(syncOptions ?? null)}</output>
40
+ {children}
41
+ </>
42
+ );
43
+ },
44
+ }));
45
+ vi.mock("./TanstackProvider", () => ({ createTanstackProvider: vi.fn() }));
46
+ vi.mock("./loadWalletProvider", () => ({ default: () => null }));
47
+
48
+ function renderedSyncOptions(frappe: FrappeConfig = {}) {
49
+ const { AppProvider } = createApp({
50
+ frappe: { enabled: true, baseURL: "https://bench.test", ...frappe },
51
+ });
52
+ const html = renderToStaticMarkup(<AppProvider />);
53
+ const json = html.match(/<output>(.*)<\/output>/)?.[1].replace(/&quot;/g, '"');
54
+ return json === undefined ? undefined : JSON.parse(json);
55
+ }
56
+
57
+ afterEach(() => {
58
+ for (const key of Object.keys(env)) delete env[key];
59
+ });
60
+
61
+ describe("createApp frappe sync options (MPO-324)", () => {
62
+ it("hands the provider snapshot catch-up when FRAPPE_CATCH_UP_STRATEGY=snapshot", () => {
63
+ env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
64
+ expect(renderedSyncOptions()).toEqual({ catchUpStrategy: "snapshot" });
65
+ });
66
+
67
+ it("reads the strategy when the provider renders, not when the app is created", () => {
68
+ const { AppProvider } = createApp({ frappe: { enabled: true, baseURL: "https://bench.test" } });
69
+ env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
70
+ expect(renderToStaticMarkup(<AppProvider />)).toContain(
71
+ "{&quot;catchUpStrategy&quot;:&quot;snapshot&quot;}",
72
+ );
73
+ });
74
+
75
+ it("leaves CDC as the default when the env is unset or names no strategy", () => {
76
+ expect(renderedSyncOptions()).toBeNull();
77
+ env.FRAPPE_CATCH_UP_STRATEGY = "live";
78
+ expect(renderedSyncOptions()).toBeNull();
79
+ });
80
+
81
+ it("passes the app's own sync options through and adds the env strategy to them", () => {
82
+ expect(renderedSyncOptions({ syncOptions: { cdcInterval: 30000 } })).toEqual({
83
+ cdcInterval: 30000,
84
+ });
85
+ env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
86
+ expect(renderedSyncOptions({ syncOptions: { cdcInterval: 30000 } })).toEqual({
87
+ cdcInterval: 30000,
88
+ catchUpStrategy: "snapshot",
89
+ });
90
+ });
91
+
92
+ it("lets a catchUpStrategy the app sets win over the env", () => {
93
+ env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
94
+ expect(renderedSyncOptions({ syncOptions: { catchUpStrategy: "cdc" } })).toEqual({
95
+ catchUpStrategy: "cdc",
96
+ });
97
+ });
98
+ });
99
+
100
+ describe("createApp frappe navigation (MPO-333)", () => {
101
+ it("hands the provider One's router for routed FrappeListView and FrappeForm", () => {
102
+ renderedSyncOptions();
103
+ const navigation = provided.navigation;
104
+ expect(navigation).toBeDefined();
105
+ expect(navigation?.usePathname()).toBe("/moves/a1b2c3");
106
+ navigation?.useNavigate()("/moves/a1b2c3/edit");
107
+ expect(router.pushed).toEqual(["/moves/a1b2c3/edit"]);
108
+ });
109
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Where createApp's FrappeProvider dials, per page kind (MPO-328).
3
+ *
4
+ * One renders SPA-mode pages (`/`, `/backoffice/*`) to static HTML at build
5
+ * time, so their runtime config payload holds the build's `.env` values, not
6
+ * the container's BASE_URL. Live on https://pokemon.bitspur.com that sent the
7
+ * backoffice Desk to http://pokemon.bitspur.com:8000 and realtime to :9000,
8
+ * while the server-rendered /pokemon, which carries the runtime BASE_URL,
9
+ * reached its own origin.
10
+ */
11
+ import { renderToStaticMarkup } from "react-dom/server";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import type { ReactNode } from "react";
14
+ import { createApp, followPageOriginForLoopback } from "./CreateApp";
15
+
16
+ const { env, mode } = vi.hoisted(() => ({
17
+ env: {} as Record<string, string | undefined>,
18
+ mode: { dev: false },
19
+ }));
20
+
21
+ vi.mock("@multiplatform.one/platform", () => ({
22
+ config: { get: (key: string) => env[key] },
23
+ get isDev() {
24
+ return mode.dev;
25
+ },
26
+ isWeb: true,
27
+ }));
28
+ vi.mock("one", () => ({ usePathname: () => "/", useRouter: () => ({ push: () => undefined }) }));
29
+ vi.mock("@multiplatform.one/theme", () => ({
30
+ ThemeProvider: ({ children }: { children: ReactNode }) => children,
31
+ }));
32
+ vi.mock("@multiplatform.one/frappe-ui", () => ({
33
+ FrappeProvider: ({
34
+ children,
35
+ baseURL,
36
+ socketPort,
37
+ }: {
38
+ children: ReactNode;
39
+ baseURL?: string;
40
+ socketPort?: number;
41
+ }) => (
42
+ <>
43
+ <output>{JSON.stringify({ baseURL, socketPort: socketPort ?? null })}</output>
44
+ {children}
45
+ </>
46
+ ),
47
+ }));
48
+ vi.mock("./TanstackProvider", () => ({ createTanstackProvider: vi.fn() }));
49
+ vi.mock("./loadWalletProvider", () => ({ default: () => null }));
50
+
51
+ function openPage(href: string) {
52
+ vi.stubGlobal("window", { location: new URL(href) });
53
+ }
54
+
55
+ function connection(): { baseURL?: string; socketPort: number | null } {
56
+ const { AppProvider } = createApp({ frappe: { enabled: true } });
57
+ const html = renderToStaticMarkup(<AppProvider />);
58
+ const json = html.match(/<output>(.*)<\/output>/)?.[1].replace(/&quot;/g, '"');
59
+ if (json === undefined) throw new Error("FrappeProvider did not render");
60
+ return JSON.parse(json);
61
+ }
62
+
63
+ afterEach(() => {
64
+ for (const key of Object.keys(env)) delete env[key];
65
+ mode.dev = false;
66
+ vi.unstubAllGlobals();
67
+ });
68
+
69
+ describe("createApp frappe connection by page kind (MPO-328)", () => {
70
+ it("an SPA shell with no runtime BASE_URL on an https origin dials its own origin", () => {
71
+ env.FRAPPE_SOCKETIO_PORT = "9000";
72
+ openPage("https://pokemon.bitspur.com/backoffice/pokemon");
73
+ expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
74
+ });
75
+
76
+ it("an SPA shell carrying the build's loopback BASE_URL dials its own origin too", () => {
77
+ env.BASE_URL = "http://localhost:8000";
78
+ env.FRAPPE_SOCKETIO_PORT = "9000";
79
+ openPage("https://pokemon.bitspur.com/backoffice/pokemon/pikachu");
80
+ expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
81
+ });
82
+
83
+ it("an SSR page keeps the runtime BASE_URL in the browser", () => {
84
+ env.BASE_URL = "https://pokemon.bitspur.com";
85
+ env.FRAPPE_SOCKETIO_PORT = "9000";
86
+ openPage("https://pokemon.bitspur.com/pokemon");
87
+ expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
88
+ });
89
+
90
+ it("an SSR page renders the runtime BASE_URL on the server", () => {
91
+ env.BASE_URL = "https://pokemon.bitspur.com";
92
+ env.FRAPPE_SOCKETIO_PORT = "9000";
93
+ expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: 9000 });
94
+ });
95
+
96
+ it("dev on localhost:3456 dials the bench behind :8000 with the socketio port", () => {
97
+ mode.dev = true;
98
+ env.BASE_URL = "http://localhost:8000";
99
+ env.FRAPPE_SOCKETIO_PORT = "9000";
100
+ openPage("http://localhost:3456/backoffice/pokemon");
101
+ expect(connection()).toEqual({ baseURL: "http://localhost:8000", socketPort: 9000 });
102
+ });
103
+
104
+ it("dev without BASE_URL falls back to the :8000 bench", () => {
105
+ mode.dev = true;
106
+ env.FRAPPE_SOCKETIO_PORT = "9000";
107
+ openPage("http://localhost:3456/");
108
+ expect(connection()).toEqual({ baseURL: "http://localhost:8000", socketPort: 9000 });
109
+ });
110
+
111
+ it("dev over the LAN keeps the :8000 bench on the page host", () => {
112
+ mode.dev = true;
113
+ env.BASE_URL = "http://localhost:8000";
114
+ env.FRAPPE_SOCKETIO_PORT = "9000";
115
+ openPage("http://10.236.0.10:3456/backoffice/pokemon");
116
+ expect(connection()).toEqual({ baseURL: "http://10.236.0.10:8000", socketPort: 9000 });
117
+ });
118
+
119
+ it("native has no page location, so the resolved dev origin passes through", () => {
120
+ env.BASE_URL = "http://192.168.1.20:8000";
121
+ env.FRAPPE_SOCKETIO_PORT = "9000";
122
+ vi.stubGlobal("window", {});
123
+ expect(connection()).toEqual({ baseURL: "http://192.168.1.20:8000", socketPort: 9000 });
124
+ });
125
+ });
126
+
127
+ describe("followPageOriginForLoopback", () => {
128
+ const page = (href: string) => new URL(href);
129
+
130
+ it("takes scheme, host and port from a production page", () => {
131
+ expect(
132
+ followPageOriginForLoopback("http://localhost:8000", page("https://app.example"), false),
133
+ ).toBe("https://app.example");
134
+ expect(
135
+ followPageOriginForLoopback("http://127.0.0.1:8000", page("http://10.0.0.5:3000/x"), false),
136
+ ).toBe("http://10.0.0.5:3000");
137
+ });
138
+
139
+ it("keeps a configured path", () => {
140
+ expect(
141
+ followPageOriginForLoopback(
142
+ "http://localhost:8000/bench",
143
+ page("https://app.example"),
144
+ false,
145
+ ),
146
+ ).toBe("https://app.example/bench");
147
+ });
148
+
149
+ it("swaps only the host in dev", () => {
150
+ expect(
151
+ followPageOriginForLoopback("http://localhost:8000", page("http://10.0.0.5:3456"), true),
152
+ ).toBe("http://10.0.0.5:8000");
153
+ });
154
+
155
+ it("leaves a real bench host alone", () => {
156
+ expect(
157
+ followPageOriginForLoopback("https://bench.example", page("https://app.example"), false),
158
+ ).toBe("https://bench.example");
159
+ });
160
+
161
+ it("leaves a loopback page alone", () => {
162
+ expect(
163
+ followPageOriginForLoopback("http://localhost:8000", page("http://127.0.0.1:3456"), false),
164
+ ).toBe("http://localhost:8000");
165
+ });
166
+
167
+ it("keeps the dev rewrite on non-http pages", () => {
168
+ expect(
169
+ followPageOriginForLoopback(
170
+ "http://localhost:8000",
171
+ { protocol: "tauri:", hostname: "tauri.localhost", port: "" },
172
+ false,
173
+ ),
174
+ ).toBe("http://tauri.localhost:8000");
175
+ });
176
+
177
+ it("passes through without a page", () => {
178
+ expect(followPageOriginForLoopback("http://localhost:8000", undefined, false)).toBe(
179
+ "http://localhost:8000",
180
+ );
181
+ });
182
+ });
@@ -3,15 +3,19 @@ import React, {
3
3
  createContext,
4
4
  lazy,
5
5
  useContext,
6
+ useMemo,
6
7
  type ComponentType,
7
8
  type PropsWithChildren,
8
9
  type ReactNode,
9
10
  } from "react";
10
- import { config, isWeb } from "@multiplatform.one/platform";
11
+ import { config, isDev, isWeb } from "@multiplatform.one/platform";
11
12
  import type { ThemeConfig } from "@multiplatform.one/theme";
12
13
  import { ThemeProvider } from "@multiplatform.one/theme";
13
14
  import { FrappeProvider } from "@multiplatform.one/frappe-ui";
14
- import type { FixtureProvider } from "@multiplatform.one/frappe";
15
+ import type { FixtureProvider, SyncOptions } from "@multiplatform.one/frappe";
16
+ import { frappeSocketPort } from "./frappeSocketPort";
17
+ import { frappeSyncOptions } from "./frappeSyncOptions";
18
+ import { oneFrappeNavigation } from "./oneFrappeNavigation";
15
19
  import { createTanstackProvider } from "./TanstackProvider";
16
20
  // Platform-split: native -> bridge-only web3 via the `/native` subpath (no connectkit
17
21
  // in Hermes); web -> passthrough (web mounts the provider at screen scope). Importing a
@@ -23,23 +27,53 @@ export type ProviderComponent = ComponentType<PropsWithChildren>;
23
27
 
24
28
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
25
29
 
30
+ /** The parts of `window.location` the loopback rewrite reads. */
31
+ export interface PageLocation {
32
+ protocol: string;
33
+ hostname: string;
34
+ port: string;
35
+ }
36
+
37
+ function currentPageLocation(): PageLocation | undefined {
38
+ if (typeof window === "undefined" || !window.location?.hostname) return undefined;
39
+ return window.location;
40
+ }
41
+
26
42
  /**
27
43
  * Follow the page origin when a loopback-configured backend is opened from a
28
- * non-loopback host. Dev configs point at `http://localhost:8000`, which is
29
- * correct on the dev machine but breaks the moment the app is opened via a
30
- * LAN IP (`http://10.x.x.x:3456` → the API and the Socket.IO handshake would
31
- * target the *visitor's* machine, and Chromium's Local Network Access policy
32
- * flags the cross-origin loopback fetch). Rewriting only the hostname keeps
33
- * the configured scheme and port, and only ever triggers in a browser whose
34
- * page host is itself not loopback — SSR and same-machine dev are untouched.
44
+ * non-loopback host.
45
+ *
46
+ * Dev: configs point at `http://localhost:8000`, which is correct on the dev
47
+ * machine but breaks the moment the app is opened via a LAN IP
48
+ * (`http://10.x.x.x:3456` → the API and the Socket.IO handshake would target
49
+ * the *visitor's* machine, and Chromium's Local Network Access policy flags
50
+ * the cross-origin loopback fetch). Rewriting only the hostname keeps the
51
+ * configured scheme and port, so the dev bench beside the dev server answers.
52
+ *
53
+ * Production: One renders SPA-mode pages (`/`, `/backoffice/*`) to static
54
+ * HTML at build time, so their config payload carries the build's `.env`
55
+ * BASE_URL, not the container's. A deployment serves the bench through its
56
+ * own origin (MPO-322), so scheme, host and port all follow the page, and
57
+ * `frappeSocketPort` then drops the baked socketio port (MPO-328).
58
+ *
59
+ * Only ever triggers in a browser whose page host is itself not loopback, so
60
+ * SSR, native and same-machine dev are untouched. Non-http(s) pages (Tauri,
61
+ * extensions) keep the dev rewrite.
35
62
  */
36
- export function followPageOriginForLoopback(configured: string): string {
37
- if (typeof window === "undefined" || !window.location?.hostname) return configured;
63
+ export function followPageOriginForLoopback(
64
+ configured: string,
65
+ page: PageLocation | undefined = currentPageLocation(),
66
+ dev: boolean = isDev,
67
+ ): string {
68
+ if (!page?.hostname) return configured;
38
69
  try {
39
70
  const url = new URL(configured);
40
- const pageHost = window.location.hostname;
41
- if (!LOOPBACK_HOSTS.has(url.hostname) || LOOPBACK_HOSTS.has(pageHost)) return configured;
42
- url.hostname = pageHost;
71
+ if (!LOOPBACK_HOSTS.has(url.hostname) || LOOPBACK_HOSTS.has(page.hostname)) return configured;
72
+ url.hostname = page.hostname;
73
+ if (!dev && (page.protocol === "https:" || page.protocol === "http:")) {
74
+ url.protocol = page.protocol;
75
+ url.port = page.port;
76
+ }
43
77
  return url.toString().replace(/\/$/, "");
44
78
  } catch {
45
79
  return configured;
@@ -51,7 +85,11 @@ export function followPageOriginForLoopback(configured: string): string {
51
85
  // ---------------------------------------------------------------------------
52
86
 
53
87
  export interface TanstackConfig {
54
- /** Show React Query devtools (web only, ignored on native and in Storybook). */
88
+ /**
89
+ * Show React Query devtools (web only, ignored on native and in Storybook).
90
+ * Omitted, they show in a dev build or when DEBUG=1, and a production
91
+ * visitor never loads them.
92
+ */
55
93
  debug?: boolean;
56
94
  /**
57
95
  * Shake the device to open the devtools shell (web only; requires
@@ -136,6 +174,12 @@ export interface FrappeConfig {
136
174
  token?: string | (() => string | Promise<string>);
137
175
  type?: string;
138
176
  };
177
+ /**
178
+ * Options for the live data engine. When `catchUpStrategy` is omitted,
179
+ * FRAPPE_CATCH_UP_STRATEGY (`cdc` or `snapshot`) is read at runtime; a
180
+ * bench without the `live` app needs `snapshot`, since CDC catch-up calls it.
181
+ */
182
+ syncOptions?: SyncOptions;
139
183
  /**
140
184
  * Fixture corpus every Frappe read and write is served from — the whole
141
185
  * app runs with no bench and no network. Setting it enables the provider
@@ -348,6 +392,11 @@ export function createApp(appConfig: CreateAppConfig) {
348
392
  const cfg: FrappeConfig = appConfig.frappe === true ? {} : appConfig.frappe;
349
393
 
350
394
  function FrappeProviderWrapper({ children }: PropsWithChildren) {
395
+ const configuredCatchUp = config.get("FRAPPE_CATCH_UP_STRATEGY");
396
+ const syncOptions = useMemo(
397
+ () => frappeSyncOptions(cfg.syncOptions, configuredCatchUp),
398
+ [configuredCatchUp],
399
+ );
351
400
  // A fixture corpus is an explicit opt-in, so it enables the provider
352
401
  // without FRAPPE_ENABLED=1 in .env.
353
402
  const enabled = cfg.enabled ?? (!!cfg.fixtures || config.get("FRAPPE_ENABLED") === "1");
@@ -363,7 +412,7 @@ export function createApp(appConfig: CreateAppConfig) {
363
412
  : cfg.baseURL || config.get("BASE_URL") || "http://localhost:8000";
364
413
  const baseURL = configured ? followPageOriginForLoopback(configured) : undefined;
365
414
  const socketPort =
366
- cfg.socketPort ?? (Number(config.get("FRAPPE_SOCKETIO_PORT")) || undefined);
415
+ cfg.socketPort ?? frappeSocketPort(config.get("FRAPPE_SOCKETIO_PORT"), baseURL);
367
416
 
368
417
  // Keycloak auth uses ONE transport on every platform: the HttpClient
369
418
  // and the Socket.IO handshake send `Authorization: Bearer <keycloak
@@ -394,7 +443,9 @@ export function createApp(appConfig: CreateAppConfig) {
394
443
  realtime={cfg.realtime}
395
444
  socketPort={socketPort}
396
445
  auth={auth}
446
+ syncOptions={syncOptions}
397
447
  fixtures={cfg.fixtures}
448
+ navigation={oneFrappeNavigation}
398
449
  >
399
450
  {children}
400
451
  </FrappeProvider>
@@ -0,0 +1,97 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToString } from "react-dom/server";
3
+ import { act } from "react";
4
+ import { hydrateRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import type { ReactNode } from "react";
7
+ import { createRootLayout } from "./CreateRootLayout.web";
8
+
9
+ vi.mock("one", () => ({ LoadProgressBar: () => null, Slot: () => null, useMatches: () => [] }));
10
+ vi.mock("tamagui", async () => ({
11
+ useDidFinishSSR: (await import("@tamagui/use-did-finish-ssr")).useDidFinishSSR,
12
+ }));
13
+ const scheme = vi.hoisted(() => ({ value: "light", stored: "dark", system: "dark" }));
14
+ vi.mock("@vxrn/color-scheme", () => ({
15
+ SchemeProvider: ({ children }: { children: ReactNode }) => children,
16
+ setUserScheme: (value: string) => {
17
+ scheme.value = value === "system" ? scheme.system : value;
18
+ },
19
+ useUserScheme: () => ({ value: scheme.value }),
20
+ }));
21
+ vi.mock("i18next", () => ({
22
+ default: {
23
+ use: () => ({ init: () => Promise.resolve() }),
24
+ changeLanguage: vi.fn(),
25
+ language: "en",
26
+ },
27
+ }));
28
+ vi.mock("react-i18next", () => ({ initReactI18next: {} }));
29
+ vi.mock("@multiplatform.one/platform", () => ({
30
+ config: { get: (_key: string, fallback?: string) => fallback },
31
+ isDev: false,
32
+ platform: { isServer: true },
33
+ }));
34
+ vi.mock("@multiplatform.one/i18n", () => ({ createI18nConfig: () => ({}) }));
35
+ vi.mock("@multiplatform.one/logger", () => ({ logger: { error: vi.fn() } }));
36
+ vi.mock("@multiplatform.one/store", () => ({ storage: { getItem: () => scheme.stored } }));
37
+ vi.mock("./RuntimePublicConfigScript", () => ({ RuntimePublicConfigScript: () => null }));
38
+
39
+ describe("web root scheme hydration", () => {
40
+ it.each(["dark", "system", "light"])(
41
+ "hydrates server markup before adopting %s preference",
42
+ async (stored) => {
43
+ scheme.stored = stored;
44
+ scheme.system = "dark";
45
+ const observed: string[] = [];
46
+ const Provider = ({
47
+ systemTheme,
48
+ children,
49
+ }: {
50
+ systemTheme: string;
51
+ children?: ReactNode;
52
+ }) => {
53
+ observed.push(systemTheme);
54
+ return (
55
+ <section
56
+ data-scheme={systemTheme}
57
+ style={{ color: systemTheme === "dark" ? "white" : "black" }}
58
+ >
59
+ {children}
60
+ </section>
61
+ );
62
+ };
63
+ const Layout = createRootLayout({
64
+ AppProvider: Provider,
65
+ RootLayout: ({ children }: { children?: ReactNode }) => children,
66
+ i18n: { defaultLanguage: "en" },
67
+ } as Parameters<typeof createRootLayout>[0]);
68
+ scheme.value = "light";
69
+ document.documentElement.lang = "en";
70
+ document.documentElement.innerHTML = renderToString(<Layout />).replace(
71
+ /^<html[^>]*>|<\/html>$/g,
72
+ "",
73
+ );
74
+ observed.length = 0;
75
+ scheme.value = stored === "system" ? "dark" : stored;
76
+ const errors: unknown[] = [];
77
+ const consoleError = vi.spyOn(console, "error").mockImplementation((...args) => {
78
+ errors.push(args);
79
+ });
80
+ let root: ReturnType<typeof hydrateRoot>;
81
+ await act(async () => {
82
+ root = hydrateRoot(document, <Layout />, {
83
+ onRecoverableError: (error) => errors.push(error),
84
+ });
85
+ });
86
+ expect(observed[0]).toBe("light");
87
+ expect(document.querySelector("[data-scheme]")?.getAttribute("data-scheme")).toBe(
88
+ stored === "system" ? "dark" : stored,
89
+ );
90
+ expect(
91
+ errors.filter((error) => /hydration|hydrated|didn't match/i.test(String(error))),
92
+ ).toEqual([]);
93
+ await act(async () => root!.unmount());
94
+ consoleError.mockRestore();
95
+ },
96
+ );
97
+ });