@multiplatform.one/theme 7.18.0 → 7.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.18.0",
3
+ "version": "7.20.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -44,8 +44,8 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@multiplatform.one/platform": "7.18.0",
48
- "@multiplatform.one/store": "7.18.0",
47
+ "@multiplatform.one/platform": "7.20.0",
48
+ "@multiplatform.one/store": "7.20.0",
49
49
  "@tamagui/colors": "2.7.6",
50
50
  "@tamagui/config": "2.7.6",
51
51
  "@tamagui/get-token": "2.7.6",
@@ -57,8 +57,8 @@
57
57
  "react-cookie": "^8.1.2"
58
58
  },
59
59
  "devDependencies": {
60
- "@multiplatform.one/config": "7.18.0",
61
- "@multiplatform.one/test-utils": "7.18.0",
60
+ "@multiplatform.one/config": "7.20.0",
61
+ "@multiplatform.one/test-utils": "7.20.0",
62
62
  "@tamagui/animations-css": "2.7.6",
63
63
  "@tamagui/animations-reanimated": "2.7.6",
64
64
  "@tamagui/font-inter": "2.7.6",
@@ -0,0 +1,115 @@
1
+ /**
2
+ * MPO-376: the server has no viewport, so it renders the `medium` size class.
3
+ * A component that branches on `useLayoutSizeClass() === "compact"` (the
4
+ * scaffold Navbar's desktop list vs phone menu) must hydrate that markup at a
5
+ * phone width, then adopt `compact`. Reading the viewport on the first client
6
+ * render is a mismatch React answers by throwing the server tree away.
7
+ * Native has no server HTML, so its first render reads the window directly.
8
+ */
9
+ import { act } from "react";
10
+ import { hydrateRoot, type Root } from "react-dom/client";
11
+ import { renderToString } from "react-dom/server";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const host = vi.hoisted(() => ({ web: true, nativeWidth: 390 }));
15
+
16
+ vi.mock(import("tamagui"), async (importOriginal) => ({
17
+ ...(await importOriginal()),
18
+ get isWeb() {
19
+ return host.web;
20
+ },
21
+ useWindowDimensions: () => ({ width: host.nativeWidth, height: 800, scale: 2, fontScale: 1 }),
22
+ }));
23
+
24
+ import { useLayoutSizeClass } from "./layoutTokens";
25
+
26
+ function NavFixture() {
27
+ const sizeClass = useLayoutSizeClass();
28
+ return (
29
+ <nav data-size-class={sizeClass}>
30
+ {sizeClass === "compact" ? (
31
+ <button type="button">Menu</button>
32
+ ) : (
33
+ <ul>
34
+ <li>Home</li>
35
+ <li>Pokemon</li>
36
+ </ul>
37
+ )}
38
+ </nav>
39
+ );
40
+ }
41
+
42
+ let root: Root | undefined;
43
+ let container: HTMLDivElement | undefined;
44
+ let errors: unknown[] = [];
45
+
46
+ function serverRender(): string {
47
+ const browserWindow = window;
48
+ vi.stubGlobal("window", undefined);
49
+ try {
50
+ return renderToString(<NavFixture />);
51
+ } finally {
52
+ vi.stubGlobal("window", browserWindow);
53
+ }
54
+ }
55
+
56
+ async function hydrateAt(width: number) {
57
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: width, writable: true });
58
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
59
+ const html = serverRender();
60
+ container = document.createElement("div");
61
+ container.innerHTML = html;
62
+ document.body.append(container);
63
+ const serverNav = container.querySelector("nav");
64
+ errors = [];
65
+ vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args));
66
+ await act(async () => {
67
+ root = hydrateRoot(container!, <NavFixture />, {
68
+ onRecoverableError: (error) => errors.push(error),
69
+ });
70
+ });
71
+ return { html, serverNav };
72
+ }
73
+
74
+ const hydrationErrors = () =>
75
+ errors.filter((error) => /hydration|hydrated|didn't match/i.test(String(error)));
76
+
77
+ afterEach(async () => {
78
+ if (root) await act(async () => root?.unmount());
79
+ root = undefined;
80
+ container?.remove();
81
+ container = undefined;
82
+ host.web = true;
83
+ vi.restoreAllMocks();
84
+ vi.unstubAllGlobals();
85
+ });
86
+
87
+ describe("useLayoutSizeClass through SSR hydration (MPO-376)", () => {
88
+ it("hydrates the server's medium markup at 390px, then adopts compact", async () => {
89
+ const { html, serverNav } = await hydrateAt(390);
90
+ expect(html).toContain('data-size-class="medium"');
91
+ expect(html).toContain("<ul>");
92
+
93
+ expect(hydrationErrors()).toEqual([]);
94
+ expect(container!.querySelector("nav")).toBe(serverNav);
95
+ expect(serverNav!.getAttribute("data-size-class")).toBe("compact");
96
+ expect(serverNav!.querySelector("ul")).toBeNull();
97
+ expect(serverNav!.querySelector("button")?.textContent).toBe("Menu");
98
+ });
99
+
100
+ it("hydrates at 1280px with no mismatch and adopts xl", async () => {
101
+ const { serverNav } = await hydrateAt(1280);
102
+ expect(hydrationErrors()).toEqual([]);
103
+ expect(container!.querySelector("nav")).toBe(serverNav);
104
+ expect(serverNav!.getAttribute("data-size-class")).toBe("xl");
105
+ expect(serverNav!.querySelector("ul")).not.toBeNull();
106
+ });
107
+
108
+ it("sizes a native first render from the window, with no medium frame first", () => {
109
+ host.web = false;
110
+ host.nativeWidth = 390;
111
+ expect(renderToString(<NavFixture />)).toContain('data-size-class="compact"');
112
+ host.nativeWidth = 1280;
113
+ expect(renderToString(<NavFixture />)).toContain('data-size-class="xl"');
114
+ });
115
+ });
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { isTouchable, isWebTouchable } from "@multiplatform.one/platform";
35
- import { useEffect, useState } from "react";
35
+ import { useSyncExternalStore } from "react";
36
36
  import { isWeb, useWindowDimensions } from "tamagui";
37
37
  import { useResolvedKnobs } from "./useResolvedKnobs";
38
38
 
@@ -134,46 +134,47 @@ function readViewportWidth(): number {
134
134
  return window.innerWidth;
135
135
  }
136
136
 
137
+ function subscribeViewportSizeClass(onChange: () => void): () => void {
138
+ if (!isWeb || typeof window === "undefined") return () => {};
139
+ const queries = [
140
+ layoutBreakpoints.xl,
141
+ layoutBreakpoints.large,
142
+ layoutBreakpoints.expanded,
143
+ layoutBreakpoints.medium,
144
+ ].map((width) => window.matchMedia(`(min-width: ${width}px)`));
145
+ for (const mq of queries) mq.addEventListener("change", onChange);
146
+ return () => {
147
+ for (const mq of queries) mq.removeEventListener("change", onChange);
148
+ };
149
+ }
150
+
151
+ function viewportSizeClass(): LayoutSizeClass {
152
+ return getLayoutSizeClass(readViewportWidth());
153
+ }
154
+
155
+ function serverViewportSizeClass(): LayoutSizeClass {
156
+ return getLayoutSizeClass(layoutBreakpoints.medium);
157
+ }
158
+
137
159
  /**
138
160
  * Live layout size class from viewport width — cross-platform:
139
161
  * - Web: matchMedia listeners on the class thresholds (re-renders only when
140
- * the class flips, not per resize pixel). SSR first paint → `medium`.
162
+ * the class flips, not per resize pixel). The server renders `medium`, and
163
+ * so does the hydration render (MPO-376): a first client render at the
164
+ * viewport's class would not match the server HTML, so React adopts the
165
+ * live class right after hydrating. A client-only mount reads the viewport
166
+ * on its first render.
141
167
  * - Native: react-native window dimensions (rotation / split-screen), so
142
- * phones report `compact` instead of the former pinned `medium`.
168
+ * phones report `compact` from the first render.
143
169
  */
144
170
  export function useLayoutSizeClass(): LayoutSizeClass {
145
- // Hook order is unconditional; on web the value is ignored in favor of the
146
- // matchMedia path below (threshold-only updates).
147
171
  const nativeWidth = useWindowDimensions().width;
148
- const [sizeClass, setSizeClass] = useState<LayoutSizeClass>(() =>
149
- getLayoutSizeClass(isWeb ? readViewportWidth() : nativeWidth),
172
+ const webSizeClass = useSyncExternalStore(
173
+ subscribeViewportSizeClass,
174
+ viewportSizeClass,
175
+ serverViewportSizeClass,
150
176
  );
151
-
152
- useEffect(() => {
153
- if (!isWeb || typeof window === "undefined") return;
154
-
155
- const queries = [
156
- window.matchMedia(`(min-width: ${layoutBreakpoints.xl}px)`),
157
- window.matchMedia(`(min-width: ${layoutBreakpoints.large}px)`),
158
- window.matchMedia(`(min-width: ${layoutBreakpoints.expanded}px)`),
159
- window.matchMedia(`(min-width: ${layoutBreakpoints.medium}px)`),
160
- ];
161
-
162
- const sync = () => setSizeClass(getLayoutSizeClass(window.innerWidth));
163
- sync();
164
- for (const mq of queries) mq.addEventListener("change", sync);
165
- return () => {
166
- for (const mq of queries) mq.removeEventListener("change", sync);
167
- };
168
- }, []);
169
-
170
- // Native: derive from the live dimensions; setState bails when unchanged.
171
- useEffect(() => {
172
- if (isWeb) return;
173
- setSizeClass(getLayoutSizeClass(nativeWidth));
174
- }, [nativeWidth]);
175
-
176
- return sizeClass;
177
+ return isWeb ? webSizeClass : getLayoutSizeClass(nativeWidth);
177
178
  }
178
179
 
179
180
  /** True when the viewport may show two side-by-side panes. */
@@ -95,9 +95,13 @@ export declare function layoutSizeClassAtLeast(current: LayoutSizeClass, minimum
95
95
  /**
96
96
  * Live layout size class from viewport width — cross-platform:
97
97
  * - Web: matchMedia listeners on the class thresholds (re-renders only when
98
- * the class flips, not per resize pixel). SSR first paint → `medium`.
98
+ * the class flips, not per resize pixel). The server renders `medium`, and
99
+ * so does the hydration render (MPO-376): a first client render at the
100
+ * viewport's class would not match the server HTML, so React adopts the
101
+ * live class right after hydrating. A client-only mount reads the viewport
102
+ * on its first render.
99
103
  * - Native: react-native window dimensions (rotation / split-screen), so
100
- * phones report `compact` instead of the former pinned `medium`.
104
+ * phones report `compact` from the first render.
101
105
  */
102
106
  export declare function useLayoutSizeClass(): LayoutSizeClass;
103
107
  /** True when the viewport may show two side-by-side panes. */
@@ -1 +1 @@
1
- {"version":3,"file":"layoutTokens.d.ts","sourceRoot":"","sources":["../../src/theme/layoutTokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AASH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,IAAI,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;IAC5B,uCAAuC;;IAEvC,qBAAqB;;IAErB;;;;OAIG;;IAEH;;;;;;OAMG;;IAEH,0EAA0E;;IAE1E,qBAAqB;;IAErB,qBAAqB;;IAErB,sBAAsB;;CAEd,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,KAA2B,CAAC;AAE3D,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,KAA2B,CAAC;AAE1D,eAAO,MAAM,uBAAuB,EAAE,SAAS,eAAe,EAMpD,CAAC;AAEX,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAMjE;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,SAAS,EAAE,eAAe,GAAG,CAAC,GAAG,CAAC,CAEjE;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,eAAe,GACvB,OAAO,CAET;AAQD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAiCpD;AAED,8DAA8D;AAC9D,wBAAgB,YAAY,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,OAAO,CAItD;AAID,2DAA2D;AAC3D,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,MAAM,GAAG,MAAM,GAAG,IAAuB,GACjD,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAI3C;AAID,wFAAwF;AACxF,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,kEAAkE;AAClE,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,MAAyB,GAAG,gBAAgB,CAElF;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAyB,GAC7B,aAAa,CAGf;AAID;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAIpC;;;GAGG;AACH,wBAAgB,eAAe,IAAI;IACjC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7B,aAAa,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC,CAMA"}
1
+ {"version":3,"file":"layoutTokens.d.ts","sourceRoot":"","sources":["../../src/theme/layoutTokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AASH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,IAAI,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;IAC5B,uCAAuC;;IAEvC,qBAAqB;;IAErB;;;;OAIG;;IAEH;;;;;;OAMG;;IAEH,0EAA0E;;IAE1E,qBAAqB;;IAErB,qBAAqB;;IAErB,sBAAsB;;CAEd,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,KAA2B,CAAC;AAE3D,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,KAA2B,CAAC;AAE1D,eAAO,MAAM,uBAAuB,EAAE,SAAS,eAAe,EAMpD,CAAC;AAEX,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAMjE;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,SAAS,EAAE,eAAe,GAAG,CAAC,GAAG,CAAC,CAEjE;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,eAAe,GACvB,OAAO,CAET;AA8BD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAQpD;AAED,8DAA8D;AAC9D,wBAAgB,YAAY,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,OAAO,CAItD;AAID,2DAA2D;AAC3D,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,MAAM,GAAG,MAAM,GAAG,IAAuB,GACjD,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAI3C;AAID,wFAAwF;AACxF,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,kEAAkE;AAClE,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,MAAyB,GAAG,gBAAgB,CAElF;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAyB,GAC7B,aAAa,CAGf;AAID;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAIpC;;;GAGG;AACH,wBAAgB,eAAe,IAAI;IACjC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7B,aAAa,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC,CAMA"}