@mrmeg/expo-ui 0.20.0 → 0.21.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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useRef, useState } from "react";
3
- import { Platform, ScrollView, StyleSheet, Text, View, } from "react-native";
3
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { useDimensions } from "../hooks/useDimensions.js";
6
6
  import { spacing } from "../constants/spacing.js";
@@ -56,11 +56,14 @@ function clampIndex(index, count) {
56
56
  * resolved item width.
57
57
  *
58
58
  * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
59
- * in the server-rendered tree, so web SSR ships the real content instead of an
60
- * empty scroller that only fills in after measurement. Snapping uses the
61
- * platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
59
+ * in the prerendered tree, so a web build's HTML shell ships the real content
60
+ * instead of an empty scroller that only fills in after measurement. Snapping
61
+ * uses the platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
62
62
  * animation library is involved.
63
63
  *
64
+ * Slides sized by a fractional `itemWidth` measure against the viewport until
65
+ * the first `onLayout` lands — see the `itemWidth` caveat on `CarouselProps`.
66
+ *
64
67
  * @example
65
68
  * ```tsx
66
69
  * <Carousel itemWidth={0.8} onIndexChange={setPage}>
@@ -78,9 +81,9 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
78
81
  const items = React.Children.toArray(children);
79
82
  const count = items.length;
80
83
  // Width source of truth: the measured container after mount, seeded from the
81
- // viewport so the server and the client's first render agree. `useDimensions`
82
- // reads the SSR viewport context rather than `window`, which is what keeps
83
- // that seed hydration-safe (docs/ssr-hydration.md §4).
84
+ // viewport so a prerendered HTML shell and the client's first render agree.
85
+ // `useDimensions` seeds from a constant default rather than `window` on that
86
+ // first render, which is what keeps the seed hydration-safe.
84
87
  const { width: viewportWidth } = useDimensions();
85
88
  const [measuredWidth, setMeasuredWidth] = useState(null);
86
89
  const containerWidth = measuredWidth ?? viewportWidth;
@@ -88,7 +91,8 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
88
91
  const interval = slideWidth + gap;
89
92
  const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
90
93
  // Mirrors activeIndex so the scroll handler can dedupe without re-reading
91
- // state: one settle emits many scroll events, onIndexChange must fire once.
94
+ // state: web's throttled ticks report the same page many times over, and
95
+ // onIndexChange must fire once per page.
92
96
  const activeIndexRef = useRef(activeIndex);
93
97
  const scrollRef = useRef(null);
94
98
  const hasScrolledToInitial = useRef(false);
@@ -99,9 +103,28 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
99
103
  setActiveIndex(next);
100
104
  onIndexChange?.(next);
101
105
  }, [onIndexChange]);
106
+ const isWeb = Platform.OS === "web";
102
107
  const handleScroll = useCallback((event) => {
103
108
  commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
104
109
  }, [commitIndex, count, interval]);
110
+ // Native fallback for a drag that releases with the content at rest: no
111
+ // momentum follows, so `onMomentumScrollEnd` never arrives and the settle
112
+ // would go unreported.
113
+ //
114
+ // Gated on velocity because an ungated drag-end handler is exactly the
115
+ // double-fire this replaced: a flick past a slide midpoint that snaps back
116
+ // reports the midpoint page on release and the original page on settle. A
117
+ // release that *will* glide reports a non-zero velocity (points/ms on iOS,
118
+ // density-scaled px/s on Android — either way, orders of magnitude above
119
+ // this epsilon), and momentum end is left to speak for it. A release at rest
120
+ // reports 0, and there `getCarouselIndex`'s round-to-nearest already agrees
121
+ // with where `snapToInterval` would land, so the two paths can't disagree.
122
+ const handleScrollEndDrag = useCallback((event) => {
123
+ const velocityX = event.nativeEvent.velocity?.x ?? 0;
124
+ if (Math.abs(velocityX) > 0.01)
125
+ return;
126
+ handleScroll(event);
127
+ }, [handleScroll]);
105
128
  const handleLayout = useCallback((event) => {
106
129
  const next = event.nativeEvent.layout.width;
107
130
  if (!(next > 0))
@@ -120,24 +143,54 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
120
143
  return;
121
144
  scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
122
145
  }, [interval, measuredWidth, targetIndex]);
123
- const isWeb = Platform.OS === "web";
146
+ // Re-clamp when children shrink. Without this the status line reads "5 of 3"
147
+ // with no active dot until the next scroll, because activeIndex is only
148
+ // seeded once and the scroller — already past the new content width — emits
149
+ // nothing on its own. Scrolling back is what makes the offset agree with the
150
+ // reported page again.
151
+ useEffect(() => {
152
+ if (count <= 0 || activeIndexRef.current < count)
153
+ return;
154
+ const clamped = count - 1;
155
+ commitIndex(clamped);
156
+ if (interval > 0) {
157
+ scrollRef.current?.scrollTo({ x: clamped * interval, animated: false });
158
+ }
159
+ }, [commitIndex, count, interval]);
160
+ const handleDotPress = useCallback((index) => {
161
+ commitIndex(index);
162
+ if (interval > 0) {
163
+ scrollRef.current?.scrollTo({ x: index * interval, animated: true });
164
+ }
165
+ }, [commitIndex, interval]);
124
166
  const useCssSnap = isWeb && snap;
125
167
  const dotsVisible = showDots && count > 1;
126
168
  return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
127
169
  // Native snapping. react-native-web drops these (they aren't in its
128
170
  // forwarded-prop list), so web uses the CSS scroll-snap styles instead.
129
- snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal",
130
- // react-native-web only emits scroll ticks when the throttle is > 0 and
131
- // never emits momentum events — this is what keeps the dots live for
132
- // wheel/trackpad scrolling.
133
- scrollEventThrottle: 16, onScroll: handleScroll, onMomentumScrollEnd: handleScroll, style: useCssSnap
171
+ snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal", ...(isWeb
172
+ ? { scrollEventThrottle: 16, onScroll: handleScroll }
173
+ : { onMomentumScrollEnd: handleScroll, onScrollEndDrag: handleScrollEndDrag }), style: useCssSnap
134
174
  ? [webStyles.scroller, { scrollPaddingLeft: contentPadding }]
135
- : undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(View, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, accessibilityLabel: `Slide ${index + 1} of ${count}`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }, index))) }))] }));
175
+ : undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(Pressable, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, "aria-selected": index === activeIndex, accessibilityLabel: `Slide ${index + 1} of ${count}`, hitSlop: DOT_HIT_SLOP, onPress: () => handleDotPress(index), style: styles.dotButton, children: _jsx(View, { testID: `${testID}-dot-${index}-indicator`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }) }, index))) }))] }));
136
176
  }
137
177
  // ============================================================================
138
178
  // Styles
139
179
  // ============================================================================
140
180
  const DOT_SIZE = 8;
181
+ // Press target around each 8px dot. The square is what separates the dots
182
+ // visually (the row itself has no gap, so neighboring targets are flush — no
183
+ // dead strip between them), and `DOT_HIT_SLOP` extends it vertically to
184
+ // `spacing.touchTarget` (24 + 2 x 10 = 44). The slop is vertical-only on
185
+ // purpose: horizontal slop would overlap the neighboring dot's target and make
186
+ // which dot a tap lands on ambiguous.
187
+ const DOT_HIT_SIZE = 24;
188
+ const DOT_HIT_SLOP = {
189
+ top: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
190
+ bottom: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
191
+ left: 0,
192
+ right: 0,
193
+ };
141
194
  // Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
142
195
  // site (it tracks the contentPadding prop) so the leading inset survives the
143
196
  // browser snapping slide 0 to the scrollport start — which also makes page `i`
@@ -166,8 +219,17 @@ const createStyles = (theme) => StyleSheet.create({
166
219
  flexDirection: "row",
167
220
  alignItems: "center",
168
221
  justifyContent: "center",
169
- gap: spacing.xs,
170
- marginTop: spacing.md,
222
+ // No `gap`: the DOT_HIT_SIZE squares carry the visual spacing, and a gap
223
+ // would open an unpressable strip between adjacent targets. marginTop
224
+ // absorbs the wrapper's own vertical padding so the row sits where the
225
+ // pre-Pressable dots did.
226
+ marginTop: spacing.md - (DOT_HIT_SIZE - DOT_SIZE) / 2,
227
+ },
228
+ dotButton: {
229
+ width: DOT_HIT_SIZE,
230
+ height: DOT_HIT_SIZE,
231
+ alignItems: "center",
232
+ justifyContent: "center",
171
233
  },
172
234
  dot: {
173
235
  width: DOT_SIZE,
@@ -5,9 +5,20 @@ export interface LabelProps {
5
5
  */
6
6
  children: string;
7
7
  /**
8
- * Native ID to associate with a form control
8
+ * The label's OWN id. On web react-native-web maps it to `id`; on native it
9
+ * is the `nativeID` a control's `accessibilityLabelledBy` can point at.
10
+ *
11
+ * Must differ from the paired input's id — see `htmlFor`.
9
12
  */
10
13
  nativeID?: string;
14
+ /**
15
+ * WEB ONLY — the id of the input this label describes (the input's
16
+ * `nativeID`). Passing it makes `@rn-primitives/label` render a real
17
+ * `<label for="…">`, which is what associates the two and makes clicking the
18
+ * label focus the input. No-op on native, where association goes the other
19
+ * way (the control points at the label's `nativeID`).
20
+ */
21
+ htmlFor?: string;
11
22
  /**
12
23
  * Whether the field is required (shows asterisk)
13
24
  */
@@ -37,25 +48,34 @@ export interface LabelProps {
37
48
  /**
38
49
  * Label component for form fields using @rn-primitives/label
39
50
  *
40
- * Provides accessible labeling for form controls with automatic
41
- * association via nativeID.
51
+ * Accessible labeling for form controls. Association needs **two distinct
52
+ * ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
53
+ * both the same value renders two elements with one id on web and associates
54
+ * nothing.
42
55
  *
43
56
  * Usage:
44
57
  * ```tsx
45
- * // Basic label
46
- * <Label nativeID="email-input">Email</Label>
58
+ * // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
59
+ * // clicking it focuses the input; `nativeID` names the label itself, which is
60
+ * // what a control's `accessibilityLabelledBy` points at on native.
61
+ * <Label nativeID="email-label" htmlFor="email-input">Email</Label>
47
62
  * <TextInput nativeID="email-input" />
48
63
  *
49
64
  * // Required field
50
- * <Label nativeID="password" required>Password</Label>
65
+ * <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
66
+ * <TextInput nativeID="password-input" secureTextEntry />
51
67
  *
52
68
  * // With error state
53
- * <Label nativeID="username" error>Username</Label>
69
+ * <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
70
+ *
71
+ * // Standalone caption with no control to associate — omit htmlFor
72
+ * <Label>Filters</Label>
54
73
  *
55
- * // With press handler to focus input
56
- * <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
74
+ * // With press handler to focus input (needed on native, where htmlFor is a
75
+ * // no-op and pressing the label does nothing on its own)
76
+ * <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
57
77
  * Search
58
78
  * </Label>
59
79
  * ```
60
80
  */
61
- export declare function Label({ children, nativeID, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
81
+ export declare function Label({ children, nativeID, htmlFor, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
@@ -14,28 +14,37 @@ const SIZE_CONFIGS = {
14
14
  /**
15
15
  * Label component for form fields using @rn-primitives/label
16
16
  *
17
- * Provides accessible labeling for form controls with automatic
18
- * association via nativeID.
17
+ * Accessible labeling for form controls. Association needs **two distinct
18
+ * ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
19
+ * both the same value renders two elements with one id on web and associates
20
+ * nothing.
19
21
  *
20
22
  * Usage:
21
23
  * ```tsx
22
- * // Basic label
23
- * <Label nativeID="email-input">Email</Label>
24
+ * // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
25
+ * // clicking it focuses the input; `nativeID` names the label itself, which is
26
+ * // what a control's `accessibilityLabelledBy` points at on native.
27
+ * <Label nativeID="email-label" htmlFor="email-input">Email</Label>
24
28
  * <TextInput nativeID="email-input" />
25
29
  *
26
30
  * // Required field
27
- * <Label nativeID="password" required>Password</Label>
31
+ * <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
32
+ * <TextInput nativeID="password-input" secureTextEntry />
28
33
  *
29
34
  * // With error state
30
- * <Label nativeID="username" error>Username</Label>
35
+ * <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
31
36
  *
32
- * // With press handler to focus input
33
- * <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
37
+ * // Standalone caption with no control to associate — omit htmlFor
38
+ * <Label>Filters</Label>
39
+ *
40
+ * // With press handler to focus input (needed on native, where htmlFor is a
41
+ * // no-op and pressing the label does nothing on its own)
42
+ * <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
34
43
  * Search
35
44
  * </Label>
36
45
  * ```
37
46
  */
38
- export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
47
+ export function Label({ children, nativeID, htmlFor, required, size = "md", error, disabled, style, onPress, }) {
39
48
  const { theme } = useTheme();
40
49
  const styles = themedStyles(theme);
41
50
  const sizeConfig = SIZE_CONFIGS[size];
@@ -53,7 +62,7 @@ export function Label({ children, nativeID, required, size = "md", error, disabl
53
62
  return (_jsx(LabelPrimitive.Root, { nativeID: nativeID, onPress: onPress, style: {
54
63
  ...styles.root,
55
64
  ...(Platform.OS === "web" && onPress && { cursor: "pointer" }),
56
- }, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
65
+ }, children: _jsxs(LabelPrimitive.Text, { htmlFor: htmlFor, style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
57
66
  }
58
67
  const createStyles = (theme) => StyleSheet.create({
59
68
  root: {
@@ -2,10 +2,11 @@ import { Platform } from "react-native";
2
2
  // Web font stack fallback
3
3
  const WEB_FONT_STACK = "system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"";
4
4
  // IMPORTANT: do NOT key these on `typeof document` / `typeof navigator`.
5
- // On a web bundle, Node SSR sees `undefined` for both and the client sees them,
6
- // producing different snapshot values at module load -> hydration mismatch on
7
- // every <StyledText>. `Platform.OS` (from react-native-web) returns "web" in
8
- // both environments, so the value is stable.
5
+ // A web bundle is also evaluated in Node when a host app's HTML shell is
6
+ // rendered at export time, where both are `undefined` while the browser sees
7
+ // them — that produces different module-load snapshots and a hydration
8
+ // mismatch on every <StyledText>. `Platform.OS` (from react-native-web)
9
+ // returns "web" in both environments, so the value is stable.
9
10
  const isWebRuntime = Platform.OS === "web";
10
11
  const isReactNativeRuntime = Platform.OS !== "web";
11
12
  // Georgia ships as one face per platform; every weight slot points at the
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Viewport the first web render is computed from, before the real window can
3
+ * be read. Desktop is chosen because:
4
+ * 1. A desktop visitor sees no reflow — their real viewport lands in the
5
+ * same breakpoint.
6
+ * 2. A mobile visitor gets one frame of desktop-styled content before the
7
+ * post-mount effect snaps to real dimensions — better than the inverse
8
+ * (every desktop visitor seeing mobile-tiny, then snapping).
9
+ * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
10
+ * value the HTML shell `expo export` renders in Node is built from, so
11
+ * the browser's first render agrees with the markup it hydrates.
12
+ */
13
+ export declare const DEFAULT_VIEWPORT_WIDTH = 1280;
14
+ export declare const DEFAULT_VIEWPORT_HEIGHT = 800;
1
15
  export declare const SCREEN_SIZES: {
2
16
  readonly SMALL: 768;
3
17
  readonly MEDIUM: 1000;
@@ -15,15 +29,11 @@ type WindowDimensions = {
15
29
  * Provides a consistent way to access window dimensions and screen size
16
30
  * information across mobile and web.
17
31
  *
18
- * On web SSR, the initial width comes from `SsrViewportContext`, which a
19
- * route's loader can populate from a `mrmeg-vw` cookie (precise, from
20
- * previous visit) or User-Agent heuristics. Both server and the initial
21
- * client render read the same context value so hydration matches; after
22
- * mount, real dimensions take over and the cookie is updated for next time.
23
- *
24
- * Routes that don't provide the context get the package default
25
- * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
26
- * frame of desktop layout before snapping.
32
+ * On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
33
+ * window can't be read while the HTML shell is rendered in Node at export
34
+ * time, and reading it during the browser's first render would disagree with
35
+ * that markup. A post-mount effect swaps in the real viewport and follows
36
+ * resize from there.
27
37
  */
28
38
  export declare const useDimensions: () => WindowDimensions;
29
39
  export {};
@@ -1,6 +1,19 @@
1
- import { use, useEffect, useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { Platform, useWindowDimensions } from "react-native";
3
- import { SsrViewportContext, SSR_VIEWPORT_DEFAULT_HEIGHT, } from "../state/SsrViewportContext.js";
3
+ /**
4
+ * Viewport the first web render is computed from, before the real window can
5
+ * be read. Desktop is chosen because:
6
+ * 1. A desktop visitor sees no reflow — their real viewport lands in the
7
+ * same breakpoint.
8
+ * 2. A mobile visitor gets one frame of desktop-styled content before the
9
+ * post-mount effect snaps to real dimensions — better than the inverse
10
+ * (every desktop visitor seeing mobile-tiny, then snapping).
11
+ * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
12
+ * value the HTML shell `expo export` renders in Node is built from, so
13
+ * the browser's first render agrees with the markup it hydrates.
14
+ */
15
+ export const DEFAULT_VIEWPORT_WIDTH = 1280;
16
+ export const DEFAULT_VIEWPORT_HEIGHT = 800;
4
17
  export const SCREEN_SIZES = {
5
18
  SMALL: 768,
6
19
  MEDIUM: 1000,
@@ -20,53 +33,32 @@ const calculateDimensionFlags = (width, height) => {
20
33
  isLargeScreen: width > SCREEN_SIZES.MEDIUM,
21
34
  };
22
35
  };
23
- // Persist the real viewport width as a cookie on first mount so subsequent
24
- // SSR requests can render with the user's actual layout (no flash on repeat
25
- // visits). The server reads this cookie via your route loader using
26
- // `detectSsrViewportWidth(request)` from server/lib/ssrViewport.ts.
27
- const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
28
- const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
29
- function writeViewportCookie(width) {
30
- if (typeof document === "undefined")
31
- return;
32
- // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
33
- // every pixel of horizontal movement.
34
- const rounded = Math.round(width / 10) * 10;
35
- document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
36
- }
37
36
  /**
38
37
  * Provides a consistent way to access window dimensions and screen size
39
38
  * information across mobile and web.
40
39
  *
41
- * On web SSR, the initial width comes from `SsrViewportContext`, which a
42
- * route's loader can populate from a `mrmeg-vw` cookie (precise, from
43
- * previous visit) or User-Agent heuristics. Both server and the initial
44
- * client render read the same context value so hydration matches; after
45
- * mount, real dimensions take over and the cookie is updated for next time.
46
- *
47
- * Routes that don't provide the context get the package default
48
- * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
49
- * frame of desktop layout before snapping.
40
+ * On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
41
+ * window can't be read while the HTML shell is rendered in Node at export
42
+ * time, and reading it during the browser's first render would disagree with
43
+ * that markup. A post-mount effect swaps in the real viewport and follows
44
+ * resize from there.
50
45
  */
51
46
  export const useDimensions = () => {
52
47
  const isWeb = Platform.OS === "web";
53
- const ssrWidth = use(SsrViewportContext);
54
48
  // Native reads come from useWindowDimensions, which subscribes to rotation /
55
49
  // split-screen / resize and tears the listener down for us — no manual
56
50
  // Dimensions.addEventListener to leak. On web we ignore it and drive layout
57
- // from the SSR context + the resize listener below so hydration stays exact.
51
+ // from the default frame + the resize listener below.
58
52
  const native = useWindowDimensions();
59
- // Lazy initializer: both server and client first render compute identical
60
- // flags from the context value, so hydration matches.
61
- const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
53
+ const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
62
54
  // Web: read the real viewport after mount and follow resize events. Keeping
63
- // this in an effect (not render) preserves the SSR-matched first paint.
55
+ // this in an effect (not render) keeps the first render identical to the
56
+ // exported markup it hydrates.
64
57
  useEffect(() => {
65
58
  if (!isWeb)
66
59
  return;
67
60
  const syncFromWindow = () => {
68
61
  setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
69
- writeViewportCookie(window.innerWidth);
70
62
  };
71
63
  syncFromWindow();
72
64
  window.addEventListener("resize", syncFromWindow);
@@ -1,17 +1,3 @@
1
- /**
2
- * Registers the Feather icon font in expo-font's per-request SSR store.
3
- *
4
- * Must be called from a **render body** on server-web (see `useResources`
5
- * below): expo-font enters an `AsyncLocalStorage` store per request, so a
6
- * module-scope registration lands in at most one request's store. Registering
7
- * during render means every request emits the `@font-face` rule into
8
- * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
9
- * the client's first render.
10
- *
11
- * No-op on native and on client web (both already covered: native by the
12
- * effect below, client web by the module-scope call above).
13
- */
14
- export declare function ensureIconFontRegistered(): void;
15
1
  interface LoadResourcesResult {
16
2
  loaded: boolean;
17
3
  error: Error | null;
@@ -4,45 +4,17 @@ import Feather from "@expo/vector-icons/Feather";
4
4
  import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
5
5
  import { Platform } from "react-native";
6
6
  import { useThemeStore } from "../state/themeStore.js";
7
- // Eager, module-scope load that primes the Feather font on the *client* before
8
- // first render: on web it synchronously injects the @font-face rule, which is
9
- // what makes the client's `Font.isLoaded("feather")` check read true during
10
- // hydration; on native it just starts the fetch ahead of the effect below
11
- // (which then no-ops for the already-loaded font).
7
+ // Eager, module-scope load that primes the Feather font before first render:
8
+ // on web it synchronously injects the @font-face rule, so icons have their
9
+ // glyphs on the first paint; on native it just starts the fetch ahead of the
10
+ // effect below (which then no-ops for the already-loaded font).
12
11
  //
13
- // Deliberately guarded off server-web. expo-font's SSR store is per-request
14
- // AsyncLocalStorage, and Metro's inlineRequires defers this module's evaluation
15
- // until it is first required during a render — so on the server this line only
16
- // ever populated the store of whichever request happened to warm the module
17
- // cache. Requests 2+ then shipped HTML with no @font-face and empty icons while
18
- // the client rendered glyphs (React #418). Server registration happens
19
- // per-request in ensureIconFontRegistered() instead.
12
+ // Guarded on `window` because web bundles are also evaluated in Node when
13
+ // `expo export` renders the HTML shell, where there is no document to inject
14
+ // into.
20
15
  if (typeof window !== "undefined") {
21
16
  void Font.loadAsync(Feather.font);
22
17
  }
23
- /**
24
- * Registers the Feather icon font in expo-font's per-request SSR store.
25
- *
26
- * Must be called from a **render body** on server-web (see `useResources`
27
- * below): expo-font enters an `AsyncLocalStorage` store per request, so a
28
- * module-scope registration lands in at most one request's store. Registering
29
- * during render means every request emits the `@font-face` rule into
30
- * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
31
- * the client's first render.
32
- *
33
- * No-op on native and on client web (both already covered: native by the
34
- * effect below, client web by the module-scope call above).
35
- */
36
- export function ensureIconFontRegistered() {
37
- if (Platform.OS !== "web" || typeof window !== "undefined")
38
- return;
39
- if (Font.isLoaded("feather"))
40
- return; // per-request store; cheap
41
- // Synchronous on server-web (expo-font routes to registerStaticFont). No
42
- // `void`/try-catch: a throw here means we're outside a request scope, which
43
- // must be loud.
44
- Font.loadAsync(Feather.font);
45
- }
46
18
  // The four static Inter weights StyledText's native family keys point at
47
19
  // (see constants/fonts.ts). Native-only: web never renders these family names
48
20
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -96,10 +68,6 @@ function ensureWebFontStylesheet() {
96
68
  * un-download Inter. The Feather icon font always loads.
97
69
  */
98
70
  export const useResources = () => {
99
- // Render-body (not effect) so server-web registers the icon font in *this*
100
- // request's expo-font store — effects never run on the server and a
101
- // module-scope call only reaches one request. See ensureIconFontRegistered.
102
- ensureIconFontRegistered();
103
71
  const [loaded, setLoaded] = useState(false);
104
72
  const [error, setError] = useState(null);
105
73
  useEffect(() => {
@@ -3,7 +3,6 @@ import { colors } from "../constants/colors.js";
3
3
  import { Platform, StyleSheet } from "react-native";
4
4
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
5
  import { useThemeColorScope } from "../state/themeColorScope.js";
6
- import { useSsrThemeSeed } from "../state/ssrTheme.js";
7
6
  import { spacing as spacingConstants } from "../constants/spacing.js";
8
7
  // Module-level cache for contrast calculations to avoid memory leak
9
8
  // and share across components
@@ -46,24 +45,11 @@ function getCachedOrCompute(key, compute) {
46
45
  * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
47
46
  */
48
47
  export function useTheme() {
49
- const storeUserTheme = useThemeStore((s) => s.userTheme);
50
- const storeSystemTheme = useThemeStore((s) => s.systemTheme);
51
- const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
48
+ const userTheme = useThemeStore((s) => s.userTheme);
49
+ const systemTheme = useThemeStore((s) => s.systemTheme);
52
50
  const setTheme = useThemeStore((s) => s.setTheme);
53
51
  const colorOverrides = useThemeStore((s) => s.colorOverrides);
54
52
  const scoped = useThemeColorScope();
55
- const seed = useSsrThemeSeed();
56
- // Before persistence has been read, a web render must resolve from the
57
- // per-request SSR seed instead of store state. The store is a module
58
- // singleton shared across concurrent SSR requests, so it can only ever hold
59
- // the SSR-safe boot defaults ("system"/light); the seed is cookie-derived,
60
- // which is the one signal the server and the browser read identically —
61
- // that's what keeps the hydrated tree matching the server HTML. After mount
62
- // `syncThemeFromEnvironment()` flips `hasLoadedTheme` and store state (real
63
- // localStorage/AsyncStorage + live OS listener) takes over for good.
64
- const useSeed = Platform.OS === "web" && !hasLoadedTheme;
65
- const userTheme = useSeed ? seed.userTheme : storeUserTheme;
66
- const systemTheme = useSeed ? seed.systemTheme : storeSystemTheme;
67
53
  // Determine which theme to use (user preference or system)
68
54
  const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
69
55
  const base = colors[effectiveScheme];
@@ -1,17 +1,17 @@
1
1
  import { type Theme } from "../constants/colors";
2
2
  /**
3
3
  * Registers theme-dependent styles at module scope so react-native-web
4
- * inserts their CSS rules into the server-side sheet before the document
5
- * <head> is serialized.
4
+ * inserts their CSS rules into the sheet before the prerendered document
5
+ * <head> is serialized at export time.
6
6
  *
7
7
  * Styles created lazily during render — the
8
8
  * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
9
  * RNW's sheet *after* expo-router has already captured the
10
- * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
- * HTML then references class names that have no rules, which paints
12
- * completely unstyled until hydration re-inserts them client-side (worst
13
- * on the first request after a server cold start, and on every dev
14
- * rebundle). See docs/ssr-hydration.md.
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The exported
11
+ * HTML shell then references class names that have no rules, which paints
12
+ * completely unstyled until the client re-inserts them on hydration (worst
13
+ * on a cold load of the shell, and on every dev rebundle). See "Enable
14
+ * Server Output" in docs/server-guide.md.
15
15
  *
16
16
  * Both base themes are computed eagerly at module load. Themes carrying
17
17
  * color overrides (global brand or scoped) are computed once per theme
@@ -1,17 +1,17 @@
1
1
  import { colors } from "../constants/colors.js";
2
2
  /**
3
3
  * Registers theme-dependent styles at module scope so react-native-web
4
- * inserts their CSS rules into the server-side sheet before the document
5
- * <head> is serialized.
4
+ * inserts their CSS rules into the sheet before the prerendered document
5
+ * <head> is serialized at export time.
6
6
  *
7
7
  * Styles created lazily during render — the
8
8
  * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
9
  * RNW's sheet *after* expo-router has already captured the
10
- * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
- * HTML then references class names that have no rules, which paints
12
- * completely unstyled until hydration re-inserts them client-side (worst
13
- * on the first request after a server cold start, and on every dev
14
- * rebundle). See docs/ssr-hydration.md.
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The exported
11
+ * HTML shell then references class names that have no rules, which paints
12
+ * completely unstyled until the client re-inserts them on hydration (worst
13
+ * on a cold load of the shell, and on every dev rebundle). See "Enable
14
+ * Server Output" in docs/server-guide.md.
15
15
  *
16
16
  * Both base themes are computed eagerly at module load. Themes carrying
17
17
  * color overrides (global brand or scoped) are computed once per theme
@@ -2,5 +2,3 @@ export * from "./globalUIStore";
2
2
  export * from "./notify";
3
3
  export * from "./themeStore";
4
4
  export * from "./themeColorScope";
5
- export * from "./ssrTheme";
6
- export * from "./SsrViewportContext";
@@ -2,5 +2,3 @@ export * from "./globalUIStore.js";
2
2
  export * from "./notify.js";
3
3
  export * from "./themeStore.js";
4
4
  export * from "./themeColorScope.js";
5
- export * from "./ssrTheme.js";
6
- export * from "./SsrViewportContext.js";