@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
@@ -0,0 +1,165 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * MPO-278: the web root hydrates with a light placeholder so AppProvider's
4
+ * markup matches SSR. The `<html>` scheme class is not React's: the blocking
5
+ * script from SchemeProvider writes it before first paint, from the stored
6
+ * preference or the system scheme. These run the real script out of the SSR
7
+ * markup, hydrate through the real theme ThemeProvider (what createApp's
8
+ * AppProvider renders), and watch every write to `<html class>` after it.
9
+ */
10
+ import { createDefaultThemeConfig, ThemeProvider } from "@multiplatform.one/theme";
11
+ import { runInNewContext } from "node:vm";
12
+ import type { ReactNode } from "react";
13
+ import { act, useLayoutEffect } from "react";
14
+ import { hydrateRoot } from "react-dom/client";
15
+ import { renderToString } from "react-dom/server";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+ import { createRootLayout } from "./CreateRootLayout.web";
18
+
19
+ const env = vi.hoisted(() => {
20
+ const state = { systemDark: true };
21
+ vi.stubGlobal("matchMedia", (query: string) => ({
22
+ get matches() {
23
+ return query.includes("dark") && state.systemDark;
24
+ },
25
+ media: query,
26
+ addEventListener() {},
27
+ removeEventListener() {},
28
+ addListener() {},
29
+ removeListener() {},
30
+ }));
31
+ return state;
32
+ });
33
+
34
+ vi.mock("one", () => ({ LoadProgressBar: () => null, Slot: () => null, useMatches: () => [] }));
35
+ vi.mock("i18next", () => ({
36
+ default: {
37
+ use: () => ({ init: () => Promise.resolve() }),
38
+ changeLanguage: vi.fn(),
39
+ language: "en",
40
+ },
41
+ }));
42
+ vi.mock("react-i18next", () => ({ initReactI18next: {} }));
43
+ vi.mock("@multiplatform.one/i18n", () => ({ createI18nConfig: () => ({}) }));
44
+ vi.mock("@multiplatform.one/logger", () => ({ logger: { error: vi.fn() } }));
45
+ vi.mock("@multiplatform.one/store", () => ({
46
+ storage: { getItem: (key: string) => localStorage.getItem(key) },
47
+ }));
48
+ vi.mock("./RuntimePublicConfigScript", () => ({ RuntimePublicConfigScript: () => null }));
49
+
50
+ const themeConfig = createDefaultThemeConfig();
51
+ const schemeClasses = (className: string) =>
52
+ className.split(/\s+/).filter((name) => name === "t_light" || name === "t_dark");
53
+
54
+ function runBlockingScript() {
55
+ const script = Array.from(document.querySelectorAll("script")).find((node) =>
56
+ node.textContent?.includes("vxrn-scheme"),
57
+ );
58
+ if (!script?.textContent) throw new Error("SSR markup carries no blocking scheme script");
59
+ runInNewContext(script.textContent, { document, localStorage, window });
60
+ }
61
+
62
+ afterEach(() => {
63
+ localStorage.clear();
64
+ for (const name of document.documentElement.getAttributeNames()) {
65
+ document.documentElement.removeAttribute(name);
66
+ }
67
+ });
68
+
69
+ describe("the blocking script's <html> scheme class survives hydration (MPO-278)", () => {
70
+ it.each([
71
+ ["dark", true, "dark"],
72
+ ["system", true, "dark"],
73
+ ["light", true, "light"],
74
+ ["system", false, "light"],
75
+ ] as const)(
76
+ "stored %s with a dark system of %s keeps t_%s from the script onward",
77
+ async (stored, systemDark, expected) => {
78
+ env.systemDark = systemDark;
79
+ localStorage.setItem("vxrn-scheme", stored);
80
+ const opposite = expected === "dark" ? "light" : "dark";
81
+ const observed: string[] = [];
82
+ function AppProvider({
83
+ systemTheme,
84
+ children,
85
+ }: {
86
+ systemTheme?: "light" | "dark";
87
+ children?: ReactNode;
88
+ }) {
89
+ observed.push(systemTheme ?? "light");
90
+ return (
91
+ <ThemeProvider config={themeConfig} systemTheme={systemTheme}>
92
+ {children}
93
+ </ThemeProvider>
94
+ );
95
+ }
96
+ const Layout = createRootLayout({
97
+ AppProvider,
98
+ RootLayout: ({ children }: { children?: ReactNode }) => children,
99
+ i18n: { defaultLanguage: "en" },
100
+ } as Parameters<typeof createRootLayout>[0]);
101
+
102
+ const markup = renderToString(<Layout />);
103
+ document.documentElement.lang = "en";
104
+ document.documentElement.innerHTML = markup.replace(/^<html[^>]*>|<\/html>$/g, "");
105
+ runBlockingScript();
106
+ expect(schemeClasses(document.documentElement.className)).toEqual([`t_${expected}`]);
107
+
108
+ const hydrationCommit: { scheme: string[]; colorScheme: string }[] = [];
109
+ function CommitProbe({ children }: { children: ReactNode }) {
110
+ useLayoutEffect(() => {
111
+ hydrationCommit.push({
112
+ scheme: schemeClasses(document.documentElement.className),
113
+ colorScheme: document.documentElement.style.colorScheme,
114
+ });
115
+ }, []);
116
+ return children;
117
+ }
118
+ const transitions: string[] = [];
119
+ const watcher = new MutationObserver(() => {
120
+ if (document.documentElement.classList.contains("mp-transitioning")) {
121
+ transitions.push(document.documentElement.className);
122
+ }
123
+ });
124
+ watcher.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
125
+ const errors: unknown[] = [];
126
+ const consoleError = vi.spyOn(console, "error").mockImplementation((...args) => {
127
+ errors.push(args);
128
+ });
129
+ observed.length = 0;
130
+ let root: ReturnType<typeof hydrateRoot> | undefined;
131
+ try {
132
+ await act(async () => {
133
+ root = hydrateRoot(
134
+ document,
135
+ <CommitProbe>
136
+ <Layout />
137
+ </CommitProbe>,
138
+ { onRecoverableError: (error) => errors.push(error) },
139
+ );
140
+ });
141
+ await act(async () => {});
142
+ watcher.disconnect();
143
+
144
+ expect(hydrationCommit).toEqual([
145
+ {
146
+ scheme: [`t_${expected}`],
147
+ colorScheme: expect.not.stringMatching(opposite) as unknown as string,
148
+ },
149
+ ]);
150
+ expect(observed[0]).toBe("light");
151
+ expect(observed.at(-1)).toBe(expected);
152
+ expect(schemeClasses(document.documentElement.className)).toEqual([`t_${expected}`]);
153
+ expect(document.documentElement.style.colorScheme).toBe(expected);
154
+ expect(transitions).toEqual([]);
155
+ expect(
156
+ errors.filter((error) => /hydration|hydrated|didn't match/i.test(String(error))),
157
+ ).toEqual([]);
158
+ } finally {
159
+ watcher.disconnect();
160
+ await act(async () => root?.unmount());
161
+ consoleError.mockRestore();
162
+ }
163
+ },
164
+ );
165
+ });
@@ -27,7 +27,7 @@ vi.mock("@multiplatform.one/logger", () => ({ logger: { error: vi.fn() } }));
27
27
  vi.mock("@multiplatform.one/store", () => ({ storage: {} }));
28
28
  vi.mock("./RuntimePublicConfigScript", () => ({ RuntimePublicConfigScript: () => null }));
29
29
 
30
- function renderRoot(head?: { favicon?: string; interFonts?: boolean }) {
30
+ function renderRoot(head?: { favicon?: string; interFonts?: boolean; title?: string }) {
31
31
  const Wrapper = ({ children }: { children?: ReactNode }) => children;
32
32
  const Layout = createRootLayout({
33
33
  AppProvider: Wrapper,
@@ -51,3 +51,14 @@ describe("web root viewport (MPO-257)", () => {
51
51
  expect(html).not.toContain("/fonts/inter-400.woff2");
52
52
  });
53
53
  });
54
+
55
+ describe("web root title (MPO-325)", () => {
56
+ it("renders the configured title in the server head", () => {
57
+ const html = renderRoot({ title: "multiplatform.one" });
58
+ expect(html.match(/<title>/g)).toHaveLength(1);
59
+ expect(html).toContain("<title>multiplatform.one</title>");
60
+ });
61
+ it("renders no title element when none is configured", () => {
62
+ expect(renderRoot({ favicon: "/custom.svg" })).not.toContain("<title>");
63
+ });
64
+ });
@@ -1,4 +1,5 @@
1
1
  import i18n from "i18next";
2
+ import { useDidFinishSSR } from "tamagui";
2
3
  import { LoadProgressBar, Slot, useMatches } from "one";
3
4
  import { SchemeProvider, setUserScheme, useUserScheme } from "@vxrn/color-scheme";
4
5
  import type { ComponentType, ReactNode } from "react";
@@ -85,18 +86,13 @@ export function createRootLayout(cfg: CreateRootLayoutConfig): ComponentType {
85
86
 
86
87
  const { AppProvider, RootLayout } = cfg;
87
88
 
88
- /**
89
- * Reads the resolved color scheme from `@vxrn/color-scheme` and passes
90
- * it (along with theme cookies) to the AppProvider / ThemeProvider.
91
- *
92
- * Because we eagerly call `setUserScheme()` above (before React renders),
93
- * `useUserScheme()` returns the correct value from the very first render.
94
- * No eagerScheme / warmedUp workaround needed.
95
- */
89
+ // Keep the hydration render identical to SSR. The blocking script still
90
+ // establishes the HTML scheme before paint; React adopts it after hydration.
96
91
  function WebRootProvider({ children }: { children: ReactNode }) {
97
92
  const { value: scheme } = useUserScheme();
98
93
 
99
- return <AppProvider systemTheme={scheme}>{children}</AppProvider>;
94
+ const hydrated = useDidFinishSSR();
95
+ return <AppProvider systemTheme={hydrated ? scheme : "light"}>{children}</AppProvider>;
100
96
  }
101
97
  WebRootProvider.displayName = "WebRootProvider";
102
98
 
@@ -140,7 +136,7 @@ export function createRootLayout(cfg: CreateRootLayoutConfig): ComponentType {
140
136
  ].join("");
141
137
 
142
138
  return (
143
- <html lang={htmlLang}>
139
+ <html lang={htmlLang} suppressHydrationWarning>
144
140
  <head>
145
141
  <meta charSet="utf-8" />
146
142
  {/* RUNTIME public config, first thing after charset so it is parsed
@@ -191,6 +187,7 @@ export function createRootLayout(cfg: CreateRootLayoutConfig): ComponentType {
191
187
  {cfg.head && (
192
188
  <>
193
189
  <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
190
+ {cfg.head.title && <title>{cfg.head.title}</title>}
194
191
  {cfg.head.favicon && <link rel="icon" href={cfg.head.favicon} />}
195
192
  </>
196
193
  )}
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+ // MPO-366: every visitor of pokemon.bitspur.com downloaded the devtools
3
+ // chunks and mounted #tanstack_devtools, because the app passed
4
+ // `tanstack: { debug: true }` and nothing else decided. The devtools belong
5
+ // to a dev build, or to a deployment that opts in with DEBUG=1.
6
+ import { act } from "react";
7
+ import { createRoot } from "react-dom/client";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import type { TanstackConfig } from "./CreateApp";
10
+ import { createTanstackProvider } from "./TanstackProvider";
11
+
12
+ const { env, build } = vi.hoisted(() => ({
13
+ env: {} as Record<string, string | undefined>,
14
+ build: { isDev: false },
15
+ }));
16
+
17
+ vi.mock("@multiplatform.one/platform", () => ({
18
+ config: { get: (key: string) => env[key] },
19
+ get isDev() {
20
+ return build.isDev;
21
+ },
22
+ platform: { isStorybook: false },
23
+ }));
24
+ vi.mock("./TanstackDevtools", () => ({ default: () => <div id="tanstack_devtools" /> }));
25
+
26
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27
+
28
+ async function mountsDevtools(config: TanstackConfig = {}): Promise<boolean> {
29
+ const Provider = createTanstackProvider(config);
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+ await act(async () => {
34
+ root.render(
35
+ <Provider>
36
+ <main>app</main>
37
+ </Provider>,
38
+ );
39
+ });
40
+ await act(async () => {
41
+ await new Promise((resolve) => setTimeout(resolve, 0));
42
+ });
43
+ const mounted = host.querySelector("#tanstack_devtools") !== null;
44
+ act(() => root.unmount());
45
+ host.remove();
46
+ return mounted;
47
+ }
48
+
49
+ afterEach(() => {
50
+ for (const key of Object.keys(env)) delete env[key];
51
+ build.isDev = false;
52
+ });
53
+
54
+ describe("TanStack devtools", () => {
55
+ it("stay out of a production build", async () => {
56
+ expect(await mountsDevtools()).toBe(false);
57
+ env.DEBUG = "0";
58
+ expect(await mountsDevtools()).toBe(false);
59
+ });
60
+
61
+ it("mount in a dev build", async () => {
62
+ build.isDev = true;
63
+ expect(await mountsDevtools()).toBe(true);
64
+ });
65
+
66
+ it("mount in production when the deployment sets DEBUG=1", async () => {
67
+ env.DEBUG = "1";
68
+ expect(await mountsDevtools()).toBe(true);
69
+ });
70
+
71
+ it("follow an app that decides for itself", async () => {
72
+ env.DEBUG = "1";
73
+ expect(await mountsDevtools({ debug: false })).toBe(false);
74
+ env.DEBUG = "0";
75
+ expect(await mountsDevtools({ debug: true })).toBe(true);
76
+ });
77
+ });
@@ -1,6 +1,6 @@
1
1
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
2
  import { Suspense, lazy, useEffect, useState, type PropsWithChildren } from "react";
3
- import { platform } from "@multiplatform.one/platform";
3
+ import { config, isDev, platform } from "@multiplatform.one/platform";
4
4
  import type { ProviderComponent, TanstackConfig } from "./CreateApp";
5
5
 
6
6
  const LazyDevtools = lazy(() => import("./TanstackDevtools"));
@@ -33,7 +33,8 @@ export function createTanstackProvider(tanstackConfig: TanstackConfig): Provider
33
33
  // TODO(native): TanStack Devtools does not yet support React Native. When it does,
34
34
  // revisit this guard to also handle native (e.g. !platform.isNative) so devtools
35
35
  // are enabled on native when supported.
36
- const wantsDevtools = tanstackConfig.debug && !platform.isStorybook;
36
+ const wantsDevtools =
37
+ (tanstackConfig.debug ?? (isDev || config.get("DEBUG") === "1")) && !platform.isStorybook;
37
38
  const hydrated = useHydrated();
38
39
 
39
40
  // Devtools mount CLIENT-ONLY, after hydration. Rendering the lazy
@@ -28,6 +28,29 @@ describe("deepLinkUrlToPath", () => {
28
28
  expect(deepLinkUrlToPath("https://example.com/admin")).toBe("/example.com/admin");
29
29
  });
30
30
 
31
+ it("treats an Expo Go launch URL as the dev server, not a route", () => {
32
+ expect(deepLinkUrlToPath("exp://10.236.0.10:3456")).toBeNull();
33
+ expect(deepLinkUrlToPath("exp://10.236.0.10:3456/")).toBeNull();
34
+ expect(deepLinkUrlToPath("exp://10.236.0.10:3456/--/")).toBeNull();
35
+ expect(deepLinkUrlToPath("exps://u.expo.dev/abc?runtime-version=1")).toBeNull();
36
+ });
37
+
38
+ it("routes an Expo Go link by the path after /--/", () => {
39
+ expect(deepLinkUrlToPath("exp://10.236.0.10:3456/--/pokemon/25?tab=moves")).toBe(
40
+ "/pokemon/25?tab=moves",
41
+ );
42
+ expect(deepLinkUrlToPath("exps://u.expo.dev/abc/--/wallet/")).toBe("/wallet");
43
+ });
44
+
45
+ it("treats a dev client launch URL as the dev server, not a route", () => {
46
+ expect(
47
+ deepLinkUrlToPath(
48
+ "multiplatform-one://expo-development-client/?url=http%3A%2F%2F10.236.0.10%3A3456",
49
+ ),
50
+ ).toBeNull();
51
+ expect(deepLinkUrlToPath("myapp://expo-development-client")).toBeNull();
52
+ });
53
+
31
54
  it("returns null for root-only or empty URLs (no-op, keep current route)", () => {
32
55
  expect(deepLinkUrlToPath("myapp://")).toBeNull();
33
56
  expect(deepLinkUrlToPath("myapp:///")).toBeNull();
@@ -11,10 +11,23 @@
11
11
  * the first path segment: `myapp://pos` → `/pos`). Returns null for empty /
12
12
  * root-only URLs so callers can no-op instead of clobbering the current
13
13
  * route.
14
+ *
15
+ * Expo Go launches with `exp://<dev host>` and carries a route only after
16
+ * `/--/`; the dev client launches with `<scheme>://expo-development-client`.
17
+ * Both name the dev server, not a route.
14
18
  */
15
19
  export function deepLinkUrlToPath(url: string | null | undefined): string | null {
16
- const rest = url?.match(/^[\w.+-]+:\/\/+(.*)$/)?.[1];
17
- if (!rest) return null;
18
- const path = `/${rest.replace(/\/+$/, "")}`;
20
+ if (!url) return null;
21
+ if (/^exps?:\/\//i.test(url)) {
22
+ const route = url.match(/\/--\/(.*)$/)?.[1];
23
+ return route ? toPath(route) : null;
24
+ }
25
+ const rest = url.match(/^[\w.+-]+:\/\/+(.*)$/)?.[1];
26
+ if (!rest || /^expo-development-client(?:[/?#]|$)/i.test(rest)) return null;
27
+ return toPath(rest);
28
+ }
29
+
30
+ function toPath(rest: string): string | null {
31
+ const path = `/${rest.replace(/^\/+/, "").replace(/\/+$/, "")}`;
19
32
  return path === "/" ? null : path;
20
33
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Realtime rides the page origin when the bench does (MPO-322). The deployed
3
+ * pokemon pages published BASE_URL as their own origin and still dialled
4
+ * <page host>:9000/socket.io/, because FRAPPE_SOCKETIO_PORT=9000 is baked in
5
+ * from .env at build time.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { frappeSocketPort } from "./frappeSocketPort";
9
+
10
+ describe("frappeSocketPort", () => {
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ it("drops the port when BASE_URL is the page origin", () => {
16
+ expect(
17
+ frappeSocketPort("9000", "https://pokemon.example", "https://pokemon.example"),
18
+ ).toBeUndefined();
19
+ expect(
20
+ frappeSocketPort("9000", "http://127.0.0.1:3323", "http://127.0.0.1:3323"),
21
+ ).toBeUndefined();
22
+ });
23
+
24
+ it("compares origins, so a trailing slash or a default port still matches", () => {
25
+ expect(
26
+ frappeSocketPort("9000", "https://pokemon.example:443/", "https://pokemon.example"),
27
+ ).toBeUndefined();
28
+ });
29
+
30
+ it("keeps the dev bench's socketio port beside a :8000 bench", () => {
31
+ expect(frappeSocketPort("9000", "http://localhost:8000", "http://localhost:3456")).toBe(9000);
32
+ expect(frappeSocketPort("9000", "http://10.236.0.10:8000", "http://10.236.0.10:3456")).toBe(
33
+ 9000,
34
+ );
35
+ });
36
+
37
+ it("keeps the port for a bench on another host", () => {
38
+ expect(frappeSocketPort("9000", "https://bench.example", "https://app.example")).toBe(9000);
39
+ });
40
+
41
+ it("keeps the port where there is no page (SSR, native)", () => {
42
+ expect(frappeSocketPort("9000", "https://pokemon.example", undefined)).toBe(9000);
43
+ });
44
+
45
+ it("reads the page origin from window.location by default", () => {
46
+ vi.stubGlobal("window", { location: { origin: "https://pokemon.example" } });
47
+ expect(frappeSocketPort("9000", "https://pokemon.example")).toBeUndefined();
48
+ vi.stubGlobal("window", { location: { origin: "http://localhost:3456" } });
49
+ expect(frappeSocketPort("9000", "http://localhost:8000")).toBe(9000);
50
+ });
51
+
52
+ it("returns undefined without a usable port", () => {
53
+ expect(frappeSocketPort(undefined, "http://localhost:8000", "http://localhost:3456")).toBe(
54
+ undefined,
55
+ );
56
+ expect(frappeSocketPort("", "http://localhost:8000", "http://localhost:3456")).toBe(undefined);
57
+ expect(frappeSocketPort("abc", "http://localhost:8000", "http://localhost:3456")).toBe(
58
+ undefined,
59
+ );
60
+ });
61
+
62
+ it("keeps the port when BASE_URL does not parse", () => {
63
+ expect(frappeSocketPort("9000", "not a url", "http://localhost:3456")).toBe(9000);
64
+ });
65
+ });
@@ -0,0 +1,28 @@
1
+ function currentPageOrigin(): string | undefined {
2
+ if (typeof window === "undefined") return undefined;
3
+ return window.location?.origin || undefined;
4
+ }
5
+
6
+ /**
7
+ * The Socket.IO port for FRAPPE_SOCKETIO_PORT, or undefined when the page
8
+ * reaches the bench through its own origin.
9
+ *
10
+ * FRAPPE_SOCKETIO_PORT is the dev bench's socketio port beside its web port
11
+ * (9000 next to 8000), and every build bakes it in from .env. A deployment
12
+ * whose BASE_URL is the page origin routes /socket.io/ on that origin too, so
13
+ * the baked port sent realtime to <page host>:9000, where nothing listens.
14
+ * Without a port the socket derives from BASE_URL, which is the page origin.
15
+ */
16
+ export function frappeSocketPort(
17
+ configured: string | undefined,
18
+ baseURL: string | undefined,
19
+ pageOrigin: string | undefined = currentPageOrigin(),
20
+ ): number | undefined {
21
+ const port = Number(configured) || undefined;
22
+ if (!port || !baseURL || !pageOrigin) return port;
23
+ try {
24
+ return new URL(baseURL).origin === pageOrigin ? undefined : port;
25
+ } catch {
26
+ return port;
27
+ }
28
+ }
@@ -0,0 +1,20 @@
1
+ import type { SyncOptions } from "@multiplatform.one/frappe";
2
+
3
+ /**
4
+ * The sync options the Frappe provider runs with: the app's own, plus the
5
+ * catch-up strategy FRAPPE_CATCH_UP_STRATEGY names when the app sets none.
6
+ *
7
+ * CDC catch-up calls the bench's `live` app, so a deployment whose bench does
8
+ * not carry it sets FRAPPE_CATCH_UP_STRATEGY=snapshot and every catch-up
9
+ * re-reads through /api/resource instead. The value is read when the page
10
+ * runs, not baked into the build, so one image serves both kinds of bench.
11
+ * Anything other than `cdc` or `snapshot` is ignored.
12
+ */
13
+ export function frappeSyncOptions(
14
+ syncOptions: SyncOptions | undefined,
15
+ configuredStrategy: string | undefined,
16
+ ): SyncOptions | undefined {
17
+ if (syncOptions?.catchUpStrategy) return syncOptions;
18
+ if (configuredStrategy !== "cdc" && configuredStrategy !== "snapshot") return syncOptions;
19
+ return { ...syncOptions, catchUpStrategy: configuredStrategy };
20
+ }
package/src/app/index.ts CHANGED
@@ -26,3 +26,4 @@
26
26
  */
27
27
  export * from "./CreateApp";
28
28
  export * from "./CreateRootLayout";
29
+ export * from "./runtimePublicConfigMiddleware";
@@ -0,0 +1,11 @@
1
+ import type { FrappeNavigation } from "@multiplatform.one/frappe-ui";
2
+ import { usePathname, useRouter } from "one";
3
+
4
+ /** One's router, as the routed frappe-ui views (`route` prop) read and move it. */
5
+ export const oneFrappeNavigation: FrappeNavigation = {
6
+ usePathname,
7
+ useNavigate() {
8
+ const router = useRouter();
9
+ return (href) => router.push(href as never);
10
+ },
11
+ };
@@ -5,6 +5,8 @@ import type { AppProviderProps } from "./CreateApp";
5
5
  export interface HeadConfig {
6
6
  /** Path to favicon (e.g. "/favicon.svg"). */
7
7
  favicon?: string;
8
+ /** The document `<title>`. */
9
+ title?: string;
8
10
  /**
9
11
  * Preload + declare the default Inter faces (400/700) in the document
10
12
  * head with `font-display: optional` so first paint is font-stable
@@ -0,0 +1,111 @@
1
+ // MPO-349: One serves SPA-mode pages (/moves, /backoffice/*, /) as shells
2
+ // written at BUILD time, so the browser on those pages read the CI build's
3
+ // baked config and never the deployment's. With no FRAPPE_CATCH_UP_STRATEGY
4
+ // there, the FrappeProvider ran CDC catch-up and the socket's connect sent
5
+ // live.live.api.backfill to a bench without the live app (417). SSR pages
6
+ // were fine: their document carries the runtime payload.
7
+ import { Config, runtimePublicConfigKey } from "@multiplatform.one/platform";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { frappeSyncOptions } from "./frappeSyncOptions";
10
+ import { runtimePublicConfigMiddleware } from "./runtimePublicConfigMiddleware";
11
+
12
+ // The shell `one build` writes for a SPA route (one/dist/esm/cli/buildPage.mjs).
13
+ const spaShell = `<!DOCTYPE html><html><head>
14
+ <script>globalThis['global'] = globalThis</script>
15
+ <script>globalThis['__vxrnIsSPA'] = true</script>
16
+ <script>globalThis["__one_server_context__"] = {"loaderProps":{"path":"/moves/","params":{}},"loaderData":{}}</script>
17
+ <script>globalThis.__oneLoadedCSS = new Set([])</script>
18
+ <link rel="stylesheet" href=/assets/_layout-BaJmMxQz.css />
19
+ <script type="module" src="/assets/moves-CVoBx2KM.js"></script>
20
+ </head><body></body></html>`;
21
+
22
+ // What the CI image bakes: the build's .env, with no catch-up strategy.
23
+ const bake = JSON.stringify({ BASE_URL: "http://localhost:8000", FRAPPE_ENABLED: "1" });
24
+ const publicKeys = JSON.stringify(["BASE_URL", "FRAPPE_ENABLED", "FRAPPE_CATCH_UP_STRATEGY"]);
25
+
26
+ function serve(response: Response) {
27
+ return runtimePublicConfigMiddleware({
28
+ request: new Request("https://pokemon.test/moves"),
29
+ next: async () => response,
30
+ context: {},
31
+ });
32
+ }
33
+
34
+ function html(body: string, init: ResponseInit = { headers: { "content-type": "text/html" } }) {
35
+ return new Response(body, init);
36
+ }
37
+
38
+ /** The catch-up strategy a page on this document hands its FrappeProvider. */
39
+ function pageCatchUp(document: string) {
40
+ const published = document.match(
41
+ /<script>globalThis\["__mp_public_config__"\]=(\{[^<]*\});<\/script>/,
42
+ );
43
+ if (published) {
44
+ (globalThis as Record<string, unknown>)[runtimePublicConfigKey] = JSON.parse(published[1]);
45
+ }
46
+ return frappeSyncOptions(undefined, new Config().get("FRAPPE_CATCH_UP_STRATEGY"))
47
+ ?.catchUpStrategy;
48
+ }
49
+
50
+ beforeEach(() => {
51
+ vi.stubEnv("VITE_MP_CONFIG", bake);
52
+ vi.stubEnv("VITE_MP_PUBLIC_CONFIG_KEYS", publicKeys);
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.unstubAllEnvs();
57
+ delete (globalThis as Record<string, unknown>)[runtimePublicConfigKey];
58
+ });
59
+
60
+ describe("runtimePublicConfigMiddleware", () => {
61
+ it("a page on a SPA shell reads the deployment's catch-up strategy, not the build's", async () => {
62
+ vi.stubEnv("FRAPPE_CATCH_UP_STRATEGY", "snapshot");
63
+ const served = await (await serve(html(spaShell)))!.text();
64
+ // In the browser the deployment's env is gone; only the document carries it.
65
+ vi.stubEnv("FRAPPE_CATCH_UP_STRATEGY", undefined);
66
+
67
+ expect(pageCatchUp(spaShell)).toBeUndefined();
68
+ expect(pageCatchUp(served)).toBe("snapshot");
69
+ });
70
+
71
+ it("publishes the payload ahead of the app's module scripts", async () => {
72
+ vi.stubEnv("FRAPPE_CATCH_UP_STRATEGY", "snapshot");
73
+ const served = await (await serve(html(spaShell)))!.text();
74
+ const payload = served.indexOf(runtimePublicConfigKey);
75
+ expect(payload).toBeGreaterThan(served.indexOf("<head>"));
76
+ expect(payload).toBeLessThan(served.indexOf('<script type="module"'));
77
+ expect(served).toContain('"FRAPPE_CATCH_UP_STRATEGY":"snapshot"');
78
+ });
79
+
80
+ it("keeps the shell's status and headers", async () => {
81
+ const served = (await serve(
82
+ html(spaShell, { status: 404, headers: { "content-type": "text/html", "x-shell": "1" } }),
83
+ ))!;
84
+ expect(served.status).toBe(404);
85
+ expect(served.headers.get("x-shell")).toBe("1");
86
+ expect(await served.text()).toContain(runtimePublicConfigKey);
87
+ });
88
+
89
+ it("leaves a document that already carries the payload as it is", async () => {
90
+ vi.stubEnv("FRAPPE_CATCH_UP_STRATEGY", "snapshot");
91
+ const ssr = spaShell.replace(
92
+ "<head>",
93
+ `<head><script>globalThis["${runtimePublicConfigKey}"]={"BASE_URL":"https://pokemon.test"};</script>`,
94
+ );
95
+ expect(await (await serve(html(ssr)))!.text()).toBe(ssr);
96
+ });
97
+
98
+ it("does not read a streamed SSR response", async () => {
99
+ const streamed = html(spaShell, {
100
+ headers: { "content-type": "text/html", "cache-control": "no-cache" },
101
+ });
102
+ expect(await serve(streamed)).toBe(streamed);
103
+ expect(streamed.bodyUsed).toBe(false);
104
+ });
105
+
106
+ it("passes anything that is not HTML through", async () => {
107
+ const json = new Response("{}", { headers: { "content-type": "application/json" } });
108
+ expect(await serve(json)).toBe(json);
109
+ expect(json.bodyUsed).toBe(false);
110
+ });
111
+ });