@ankhorage/surface 9.4.9 → 9.4.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ankhorage/surface
2
2
 
3
+ ## 9.4.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 45f3725: Keep responsive rendering stable through server hydration before applying browser viewport changes.
8
+
9
+ ## 9.4.10
10
+
11
+ ### Patch Changes
12
+
13
+ - 9fbc14b: Update dependencies: `@ankhorage/contracts`.
14
+
3
15
  ## 9.4.9
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/surface
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v9.4.9](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![paradox: warnings](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v9.4.11](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![paradox: warnings](././paradox/badges/docs.svg)
7
7
 
8
8
  Cross-platform UI foundation for React Native and React Native Web. Provides theming, layout primitives, and core building blocks for modern apps.
9
9
 
@@ -1 +1 @@
1
- {"version":3,"file":"ResponsiveProvider.d.ts","sourceRoot":"","sources":["../../../src/core/responsive/ResponsiveProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAI3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIjD,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,qBAY7E;AAED,wBAAgB,oBAAoB,IAAI,iBAAiB,CAQxD"}
1
+ {"version":3,"file":"ResponsiveProvider.d.ts","sourceRoot":"","sources":["../../../src/core/responsive/ResponsiveProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAG3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAKjD,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,qBAY7E;AAED,wBAAgB,oBAAoB,IAAI,iBAAiB,CAQxD"}
@@ -1,9 +1,9 @@
1
1
  import React, { createContext, use, useMemo } from 'react';
2
- import { useWindowDimensions } from 'react-native';
3
2
  import { getBreakpointFromWidth } from './getBreakpointFromWidth';
3
+ import { useResponsiveWidth } from './useResponsiveWidth';
4
4
  const ResponsiveContext = createContext(null);
5
5
  export function ResponsiveProvider({ children }) {
6
- const { width } = useWindowDimensions();
6
+ const width = useResponsiveWidth();
7
7
  const value = useMemo(() => ({
8
8
  breakpoint: getBreakpointFromWidth(width),
9
9
  width,
@@ -1 +1 @@
1
- {"version":3,"file":"ResponsiveProvider.js","sourceRoot":"","sources":["../../../src/core/responsive/ResponsiveProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGlE,MAAM,iBAAiB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAC;AAExE,MAAM,UAAU,kBAAkB,CAAC,EAAE,QAAQ,EAAiC;IAC5E,MAAM,EAAE,KAAK,EAAE,GAAG,mBAAmB,EAAE,CAAC;IAExC,MAAM,KAAK,GAAG,OAAO,CACnB,GAAG,EAAE,CAAC,CAAC;QACL,UAAU,EAAE,sBAAsB,CAAC,KAAK,CAAC;QACzC,KAAK;KACN,CAAC,EACF,CAAC,KAAK,CAAC,CACR,CAAC;IAEF,OAAO,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAG,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAEvC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;IACnF,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC","sourcesContent":["import React, { createContext, use, useMemo } from 'react';\nimport { useWindowDimensions } from 'react-native';\n\nimport { getBreakpointFromWidth } from './getBreakpointFromWidth';\nimport type { ResponsiveRuntime } from './types';\n\nconst ResponsiveContext = createContext<ResponsiveRuntime | null>(null);\n\nexport function ResponsiveProvider({ children }: { children: React.ReactNode }) {\n const { width } = useWindowDimensions();\n\n const value = useMemo<ResponsiveRuntime>(\n () => ({\n breakpoint: getBreakpointFromWidth(width),\n width,\n }),\n [width],\n );\n\n return <ResponsiveContext value={value}>{children}</ResponsiveContext>;\n}\n\nexport function useResponsiveRuntime(): ResponsiveRuntime {\n const runtime = use(ResponsiveContext);\n\n if (!runtime) {\n throw new Error('useResponsiveRuntime must be used within a ResponsiveProvider');\n }\n\n return runtime;\n}\n"]}
1
+ {"version":3,"file":"ResponsiveProvider.js","sourceRoot":"","sources":["../../../src/core/responsive/ResponsiveProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAE3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAElE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,MAAM,iBAAiB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAC;AAExE,MAAM,UAAU,kBAAkB,CAAC,EAAE,QAAQ,EAAiC;IAC5E,MAAM,KAAK,GAAG,kBAAkB,EAAE,CAAC;IAEnC,MAAM,KAAK,GAAG,OAAO,CACnB,GAAG,EAAE,CAAC,CAAC;QACL,UAAU,EAAE,sBAAsB,CAAC,KAAK,CAAC;QACzC,KAAK;KACN,CAAC,EACF,CAAC,KAAK,CAAC,CACR,CAAC;IAEF,OAAO,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAG,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAEvC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;IACnF,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC","sourcesContent":["import React, { createContext, use, useMemo } from 'react';\n\nimport { getBreakpointFromWidth } from './getBreakpointFromWidth';\nimport type { ResponsiveRuntime } from './types';\nimport { useResponsiveWidth } from './useResponsiveWidth';\n\nconst ResponsiveContext = createContext<ResponsiveRuntime | null>(null);\n\nexport function ResponsiveProvider({ children }: { children: React.ReactNode }) {\n const width = useResponsiveWidth();\n\n const value = useMemo<ResponsiveRuntime>(\n () => ({\n breakpoint: getBreakpointFromWidth(width),\n width,\n }),\n [width],\n );\n\n return <ResponsiveContext value={value}>{children}</ResponsiveContext>;\n}\n\nexport function useResponsiveRuntime(): ResponsiveRuntime {\n const runtime = use(ResponsiveContext);\n\n if (!runtime) {\n throw new Error('useResponsiveRuntime must be used within a ResponsiveProvider');\n }\n\n return runtime;\n}\n"]}
@@ -1,3 +1,4 @@
1
1
  import type { Breakpoint } from './types';
2
+ /*** Returns the hydration-stable responsive breakpoint without requiring a provider. */
2
3
  export declare function useBreakpoint(): Breakpoint;
3
4
  //# sourceMappingURL=useBreakpoint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useBreakpoint.d.ts","sourceRoot":"","sources":["../../../src/core/responsive/useBreakpoint.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,wBAAgB,aAAa,IAAI,UAAU,CAG1C"}
1
+ {"version":3,"file":"useBreakpoint.d.ts","sourceRoot":"","sources":["../../../src/core/responsive/useBreakpoint.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAG1C,wFAAwF;AACxF,wBAAgB,aAAa,IAAI,UAAU,CAG1C"}
@@ -1,7 +1,8 @@
1
- import { useWindowDimensions } from 'react-native';
2
1
  import { getBreakpointFromWidth } from './getBreakpointFromWidth';
2
+ import { useResponsiveWidth } from './useResponsiveWidth';
3
+ /*** Returns the hydration-stable responsive breakpoint without requiring a provider. */
3
4
  export function useBreakpoint() {
4
- const { width } = useWindowDimensions();
5
+ const width = useResponsiveWidth();
5
6
  return getBreakpointFromWidth(width);
6
7
  }
7
8
  //# sourceMappingURL=useBreakpoint.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useBreakpoint.js","sourceRoot":"","sources":["../../../src/core/responsive/useBreakpoint.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGlE,MAAM,UAAU,aAAa;IAC3B,MAAM,EAAE,KAAK,EAAE,GAAG,mBAAmB,EAAE,CAAC;IACxC,OAAO,sBAAsB,CAAC,KAAK,CAAC,CAAC;AACvC,CAAC","sourcesContent":["import { useWindowDimensions } from 'react-native';\n\nimport { getBreakpointFromWidth } from './getBreakpointFromWidth';\nimport type { Breakpoint } from './types';\n\nexport function useBreakpoint(): Breakpoint {\n const { width } = useWindowDimensions();\n return getBreakpointFromWidth(width);\n}\n"]}
1
+ {"version":3,"file":"useBreakpoint.js","sourceRoot":"","sources":["../../../src/core/responsive/useBreakpoint.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAElE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,wFAAwF;AACxF,MAAM,UAAU,aAAa;IAC3B,MAAM,KAAK,GAAG,kBAAkB,EAAE,CAAC;IACnC,OAAO,sBAAsB,CAAC,KAAK,CAAC,CAAC;AACvC,CAAC","sourcesContent":["import { getBreakpointFromWidth } from './getBreakpointFromWidth';\nimport type { Breakpoint } from './types';\nimport { useResponsiveWidth } from './useResponsiveWidth';\n\n/*** Returns the hydration-stable responsive breakpoint without requiring a provider. */\nexport function useBreakpoint(): Breakpoint {\n const width = useResponsiveWidth();\n return getBreakpointFromWidth(width);\n}\n"]}
@@ -0,0 +1,3 @@
1
+ /*** Reads the viewport width without changing the server and hydration render. */
2
+ export declare function useResponsiveWidth(): number;
3
+ //# sourceMappingURL=useResponsiveWidth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useResponsiveWidth.d.ts","sourceRoot":"","sources":["../../../src/core/responsive/useResponsiveWidth.ts"],"names":[],"mappings":"AAKA,kFAAkF;AAClF,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C"}
@@ -0,0 +1,21 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { Dimensions } from 'react-native';
3
+ const serverWidth = 0;
4
+ /*** Reads the viewport width without changing the server and hydration render. */
5
+ export function useResponsiveWidth() {
6
+ return useSyncExternalStore(subscribeToWindowDimensions, getWindowWidth, getServerWidth);
7
+ }
8
+ /*** Subscribes to viewport changes after hydration. */
9
+ function subscribeToWindowDimensions(onStoreChange) {
10
+ const subscription = Dimensions.addEventListener('change', onStoreChange);
11
+ return () => subscription.remove();
12
+ }
13
+ /*** Returns the current native or browser viewport width. */
14
+ function getWindowWidth() {
15
+ return Dimensions.get('window').width;
16
+ }
17
+ /*** Supplies the deterministic responsive width used by SSR and first hydration. */
18
+ function getServerWidth() {
19
+ return serverWidth;
20
+ }
21
+ //# sourceMappingURL=useResponsiveWidth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useResponsiveWidth.js","sourceRoot":"","sources":["../../../src/core/responsive/useResponsiveWidth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,MAAM,WAAW,GAAG,CAAC,CAAC;AAEtB,kFAAkF;AAClF,MAAM,UAAU,kBAAkB;IAChC,OAAO,oBAAoB,CAAC,2BAA2B,EAAE,cAAc,EAAE,cAAc,CAAC,CAAC;AAC3F,CAAC;AAED,uDAAuD;AACvD,SAAS,2BAA2B,CAAC,aAAyB;IAC5D,MAAM,YAAY,GAAG,UAAU,CAAC,gBAAgB,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;IAC1E,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC;AACrC,CAAC;AAED,6DAA6D;AAC7D,SAAS,cAAc;IACrB,OAAO,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC;AACxC,CAAC;AAED,oFAAoF;AACpF,SAAS,cAAc;IACrB,OAAO,WAAW,CAAC;AACrB,CAAC","sourcesContent":["import { useSyncExternalStore } from 'react';\nimport { Dimensions } from 'react-native';\n\nconst serverWidth = 0;\n\n/*** Reads the viewport width without changing the server and hydration render. */\nexport function useResponsiveWidth(): number {\n return useSyncExternalStore(subscribeToWindowDimensions, getWindowWidth, getServerWidth);\n}\n\n/*** Subscribes to viewport changes after hydration. */\nfunction subscribeToWindowDimensions(onStoreChange: () => void): () => void {\n const subscription = Dimensions.addEventListener('change', onStoreChange);\n return () => subscription.remove();\n}\n\n/*** Returns the current native or browser viewport width. */\nfunction getWindowWidth(): number {\n return Dimensions.get('window').width;\n}\n\n/*** Supplies the deterministic responsive width used by SSR and first hydration. */\nfunction getServerWidth(): number {\n return serverWidth;\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/surface",
3
3
  "type": "module",
4
- "version": "9.4.9",
4
+ "version": "9.4.11",
5
5
  "description": "Stable UI foundation for React Native and React Native Web.",
6
6
  "homepage": "https://github.com/ankhorage/surface#readme",
7
7
  "bugs": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@ankhorage/color-theory": "^0.3.1",
47
- "@ankhorage/contracts": "^26.0.1",
47
+ "@ankhorage/contracts": "^26.0.2",
48
48
  "@ankhorage/utility": "^1.10.0",
49
49
  "@gorhom/bottom-sheet": "^5.2.14"
50
50
  },
@@ -113,7 +113,7 @@
113
113
  },
114
114
  "devDependencies": {
115
115
  "@ankhorage/capability": "^0.2.2",
116
- "@ankhorage/devtools": "^2.3.22",
116
+ "@ankhorage/devtools": "^2.3.24",
117
117
  "@ankhorage/paradox": "^0.3.5",
118
118
  "@react-native-vector-icons/fontawesome": "^13.1.5",
119
119
  "@react-native-vector-icons/fontawesome5": "^13.1.5",
@@ -1,13 +1,13 @@
1
1
  import React, { createContext, use, useMemo } from 'react';
2
- import { useWindowDimensions } from 'react-native';
3
2
 
4
3
  import { getBreakpointFromWidth } from './getBreakpointFromWidth';
5
4
  import type { ResponsiveRuntime } from './types';
5
+ import { useResponsiveWidth } from './useResponsiveWidth';
6
6
 
7
7
  const ResponsiveContext = createContext<ResponsiveRuntime | null>(null);
8
8
 
9
9
  export function ResponsiveProvider({ children }: { children: React.ReactNode }) {
10
- const { width } = useWindowDimensions();
10
+ const width = useResponsiveWidth();
11
11
 
12
12
  const value = useMemo<ResponsiveRuntime>(
13
13
  () => ({
@@ -1,9 +1,9 @@
1
- import { useWindowDimensions } from 'react-native';
2
-
3
1
  import { getBreakpointFromWidth } from './getBreakpointFromWidth';
4
2
  import type { Breakpoint } from './types';
3
+ import { useResponsiveWidth } from './useResponsiveWidth';
5
4
 
5
+ /*** Returns the hydration-stable responsive breakpoint without requiring a provider. */
6
6
  export function useBreakpoint(): Breakpoint {
7
- const { width } = useWindowDimensions();
7
+ const width = useResponsiveWidth();
8
8
  return getBreakpointFromWidth(width);
9
9
  }
@@ -0,0 +1,25 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { Dimensions } from 'react-native';
3
+
4
+ const serverWidth = 0;
5
+
6
+ /*** Reads the viewport width without changing the server and hydration render. */
7
+ export function useResponsiveWidth(): number {
8
+ return useSyncExternalStore(subscribeToWindowDimensions, getWindowWidth, getServerWidth);
9
+ }
10
+
11
+ /*** Subscribes to viewport changes after hydration. */
12
+ function subscribeToWindowDimensions(onStoreChange: () => void): () => void {
13
+ const subscription = Dimensions.addEventListener('change', onStoreChange);
14
+ return () => subscription.remove();
15
+ }
16
+
17
+ /*** Returns the current native or browser viewport width. */
18
+ function getWindowWidth(): number {
19
+ return Dimensions.get('window').width;
20
+ }
21
+
22
+ /*** Supplies the deterministic responsive width used by SSR and first hydration. */
23
+ function getServerWidth(): number {
24
+ return serverWidth;
25
+ }
@@ -2,7 +2,18 @@ import { expect, mock, test } from 'bun:test';
2
2
  import { Window } from 'happy-dom';
3
3
  import React, { act } from 'react';
4
4
  import { renderToString } from 'react-dom/server';
5
- import * as ReactNativeWeb from 'react-native-web';
5
+
6
+ const browserWindow = new Window({ url: 'https://surface.test/' });
7
+ Object.assign(globalThis, {
8
+ IS_REACT_ACT_ENVIRONMENT: true,
9
+ Node: browserWindow.Node,
10
+ ShadowRoot: browserWindow.ShadowRoot,
11
+ document: browserWindow.document,
12
+ navigator: browserWindow.navigator,
13
+ window: browserWindow,
14
+ });
15
+
16
+ const ReactNativeWeb = await import('react-native-web');
6
17
 
7
18
  await mock.module('react-native', () => ReactNativeWeb);
8
19
 
@@ -10,7 +21,7 @@ const scrollContentStyle = { padding: 4 };
10
21
 
11
22
  const { Grid, ScrollView, View } = await import('./features/layout/public');
12
23
  const { KeyboardAvoidingView } = await import('./features/keyboard-avoiding-view/public');
13
- const { Show } = await import('./core/responsive/Show');
24
+ const { Show, useBreakpoint } = await import('./core/responsive');
14
25
  const { OverlayProvider } = await import('./internal/overlay/OverlayProvider');
15
26
  const { ThemeProvider } = await import('./features/theme/runtime');
16
27
 
@@ -24,34 +35,38 @@ function ResponsiveAcceptanceTree() {
24
35
  <ReactNativeWeb.View testID="grid-second" />
25
36
  </Grid>
26
37
  <Show
27
- fallback={<ReactNativeWeb.Text>static-fallback</ReactNativeWeb.Text>}
38
+ fallback={
39
+ <ReactNativeWeb.View testID="mobile-branch">
40
+ <ReactNativeWeb.Text>static-fallback</ReactNativeWeb.Text>
41
+ </ReactNativeWeb.View>
42
+ }
28
43
  when={{ base: false, md: true }}
29
44
  >
30
- <ReactNativeWeb.Text>wide-content</ReactNativeWeb.Text>
45
+ <ReactNativeWeb.Text testID="desktop-branch">wide-content</ReactNativeWeb.Text>
31
46
  </Show>
32
47
  </ScrollView>
33
48
  </View>
49
+ <BreakpointProbe />
34
50
  </ThemeProvider>
35
51
  );
36
52
  }
37
53
 
38
- test('RN Web 0.21 statically renders and hydrates representative responsive primitives', async () => {
54
+ function BreakpointProbe() {
55
+ return <ReactNativeWeb.Text testID="breakpoint-probe">{useBreakpoint()}</ReactNativeWeb.Text>;
56
+ }
57
+
58
+ test('RN Web 0.21 preserves SSR responsive structure during desktop hydration', async () => {
59
+ act(() => setViewportWidth(0));
39
60
  const markup = renderToString(<ResponsiveAcceptanceTree />);
40
61
 
41
62
  expect(markup).toContain('data-testid="container"');
42
63
  expect(markup).toContain('data-testid="scroll-view"');
43
64
  expect(markup).toContain('data-testid="grid"');
65
+ expect(markup).toContain('base');
44
66
  expect(markup).toContain('static-fallback');
45
67
  expect(markup).not.toContain('wide-content');
46
68
 
47
- const browserWindow = new Window({ url: 'https://surface.test/' });
48
- Object.assign(globalThis, {
49
- IS_REACT_ACT_ENVIRONMENT: true,
50
- Node: browserWindow.Node,
51
- document: browserWindow.document,
52
- navigator: browserWindow.navigator,
53
- window: browserWindow,
54
- });
69
+ act(() => setViewportWidth(1440));
55
70
  const container = browserWindow.document.createElement('div');
56
71
  container.innerHTML = markup;
57
72
  browserWindow.document.body.append(container);
@@ -64,25 +79,66 @@ test('RN Web 0.21 statically renders and hydrates representative responsive prim
64
79
  try {
65
80
  const { hydrateRoot } = await import('react-dom/client');
66
81
  const root = hydrateRoot(container as unknown as Element, <ResponsiveAcceptanceTree />);
82
+
83
+ expect(container.querySelector('[data-testid="mobile-branch"]')).not.toBeNull();
84
+ expect(container.querySelector('[data-testid="desktop-branch"]')).toBeNull();
85
+ expect(container.querySelector('[data-testid="breakpoint-probe"]')?.textContent).toBe('base');
67
86
  await act(async () => Promise.resolve());
68
87
 
69
88
  expect(container.querySelector('[data-testid="container"]')).not.toBeNull();
70
89
  expect(container.querySelector('[data-testid="scroll-view"]')).not.toBeNull();
71
90
  expect(container.querySelectorAll('[data-testid^="grid-"]')).toHaveLength(2);
91
+ expect(container.querySelector('[data-testid="mobile-branch"]')).toBeNull();
92
+ expect(container.querySelector('[data-testid="desktop-branch"]')).not.toBeNull();
93
+ expect(container.querySelector('[data-testid="breakpoint-probe"]')?.textContent).toBe('xl');
94
+ expect(hydrationErrors).toEqual([]);
95
+
96
+ act(() => root.unmount());
97
+ } finally {
98
+ console.error = originalError;
99
+ container.remove();
100
+ }
101
+ });
102
+
103
+ test('RN Web 0.21 keeps the server responsive structure at a narrow viewport', async () => {
104
+ act(() => setViewportWidth(0));
105
+ const markup = renderToString(<ResponsiveAcceptanceTree />);
106
+ act(() => setViewportWidth(375));
107
+ const container = browserWindow.document.createElement('div');
108
+ container.innerHTML = markup;
109
+ browserWindow.document.body.append(container);
110
+ const hydrationErrors: string[] = [];
111
+ const originalError = console.error;
112
+ console.error = (...values: unknown[]) => {
113
+ hydrationErrors.push(values.map(String).join(' '));
114
+ };
115
+
116
+ try {
117
+ const { hydrateRoot } = await import('react-dom/client');
118
+ const root = hydrateRoot(container as unknown as Element, <ResponsiveAcceptanceTree />);
119
+ await act(async () => Promise.resolve());
120
+
121
+ expect(container.querySelector('[data-testid="mobile-branch"]')).not.toBeNull();
122
+ expect(container.querySelector('[data-testid="desktop-branch"]')).toBeNull();
123
+ expect(container.querySelector('[data-testid="breakpoint-probe"]')?.textContent).toBe('base');
124
+ expect(container.querySelectorAll('[data-testid^="grid-"]')).toHaveLength(2);
72
125
  expect(hydrationErrors).toEqual([]);
73
126
 
74
127
  act(() => root.unmount());
75
128
  } finally {
76
129
  console.error = originalError;
77
- browserWindow.close();
78
- Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
79
- Reflect.deleteProperty(globalThis, 'Node');
80
- Reflect.deleteProperty(globalThis, 'document');
81
- Reflect.deleteProperty(globalThis, 'navigator');
82
- Reflect.deleteProperty(globalThis, 'window');
130
+ container.remove();
83
131
  }
84
132
  });
85
133
 
134
+ function setViewportWidth(width: number) {
135
+ Object.defineProperty(browserWindow.document.documentElement, 'clientWidth', {
136
+ configurable: true,
137
+ value: width,
138
+ });
139
+ browserWindow.dispatchEvent(new browserWindow.Event('resize'));
140
+ }
141
+
86
142
  test('RN Web 0.21 compiles box-none overlay pointer events into a hit-test-safe class', () => {
87
143
  const markup = renderToString(
88
144
  <OverlayProvider>