fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103)

* fix: stop leaking CSS rules from dynamic position styles

* Fix floating surfaces on web

* Stop diff controls growing Unistyles rules

* Use inline style escape hatch in diff pane

* Centralize dynamic style escape hatches

* Restore startup workspace navigation

* Upgrade Unistyles for web cleanup fix

* Update review style test for inline styles

* Use redirect for startup workspace restore

* Update startup redirect test
This commit is contained in:
Mohamed Boudra
2026-05-20 16:55:00 +08:00
committed by GitHub
parent 905a0985f9
commit f933bf6b3a
18 changed files with 366 additions and 186 deletions

View File

@@ -80,6 +80,34 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
```tsx
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const styles = StyleSheet.create({
thumb: {
position: "absolute",
},
});
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
```
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.

21
package-lock.json generated
View File

@@ -31040,17 +31040,15 @@
}
},
"node_modules/react-native-unistyles": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.0.24.tgz",
"integrity": "sha512-JWwkoXGSrxSzTBphBx1oYHTGQRyI7fup29D8kpjFdKqBLEXoOA1SzBB8phHb51kC4dCawaP1iOuyXoYm0kyCvw==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
"integrity": "sha512-S0aXJKZ6zYUViKMol+3on707AWD71fnAM7kmcHjk3FALy5QJicCCzxuluR0IptFQYNUP2dRqnnhgoS1X/U6Jpw==",
"license": "MIT",
"workspaces": [
"example",
"docs",
"expo-example"
],
"dependencies": {
"@babel/types": "7.29.0"
},
"engines": {
"node": ">= 18.0.0"
"node": ">= 20.18.0"
},
"peerDependencies": {
"@react-native/normalize-colors": "*",
@@ -31061,6 +31059,9 @@
"react-native-reanimated": "*"
},
"peerDependenciesMeta": {
"react-native-edge-to-edge": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
@@ -36562,7 +36563,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",

View File

@@ -90,7 +90,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",

View File

@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -95,20 +96,27 @@ export function DiffViewer({
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
() => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
() => [
styles.linesContainer,
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
const webVerticalContentStyle = React.useMemo(
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
[fillAvailableHeight],
);
if (!diffLines.length) {
return (
@@ -118,29 +126,39 @@ export function DiffViewer({
);
}
return (
const lines = (
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
);
const horizontalScroll = (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
{lines}
</ScrollView>
);
const content = (
<ScrollView
style={outerScrollStyle}
contentContainerStyle={styles.verticalContent}
contentContainerStyle={webVerticalContentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
</ScrollView>
{horizontalScroll}
</ScrollView>
);
return content;
}
const styles = StyleSheet.create((theme) => {

View File

@@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
@@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],

View File

@@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -79,7 +80,7 @@ function useDetailStyles(
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -88,7 +89,7 @@ function useDetailStyles(
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -97,7 +98,7 @@ function useDetailStyles(
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],

View File

@@ -20,6 +20,8 @@ import {
useWindowDimensions,
type LayoutChangeEvent,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -56,6 +58,7 @@ import {
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
const IS_WEB = isWeb;
@@ -858,7 +861,7 @@ interface DesktopContainerStyleInput {
availableHeight: number | undefined;
}
function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
@@ -877,7 +880,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
styles.desktopContainer,
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
@@ -1070,7 +1072,7 @@ interface DesktopBodyProps {
handleClose: () => void;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown;
desktopFrameStyle: StyleProp<ViewStyle>;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
@@ -1191,11 +1193,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
>
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
<Animated.View
<FloatingSurface
testID="combobox-desktop-container"
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={props.desktopContainerStyle as never}
style={styles.desktopContainer}
frameStyle={props.desktopFrameStyle}
ref={props.refs.setFloating}
collapsable={false}
onLayout={props.handleDesktopContentLayout}
@@ -1227,7 +1230,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
renderOption={props.renderOption}
/>
)}
</Animated.View>
</FloatingSurface>
</View>
</Modal>
);
@@ -1492,9 +1495,9 @@ export function Combobox({
[theme.colors.palette.zinc],
);
const desktopContainerStyle = useMemo(
const desktopFrameStyle = useMemo(
() =>
buildDesktopContainerStyle({
buildDesktopFrameStyle({
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
@@ -1561,7 +1564,7 @@ export function Combobox({
handleClose={handleClose}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle}
desktopFrameStyle={desktopFrameStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}

View File

@@ -18,7 +18,6 @@ import {
Modal,
Platform,
Pressable,
ScrollView,
StatusBar,
Text,
View,
@@ -28,7 +27,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
@@ -38,6 +37,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -498,7 +498,7 @@ export function ContextMenuContent({
[],
);
const animatedContentStyle = useMemo(() => {
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -508,7 +508,6 @@ export function ContextMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -566,23 +565,24 @@ export function ContextMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
<FloatingSurface
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={animatedContentStyle}
style={styles.content}
frameStyle={frameStyle}
>
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</ScrollView>
</Animated.View>
</FloatingScrollView>
</FloatingSurface>
</View>
</Modal>
);

View File

@@ -14,7 +14,6 @@ import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Text,
View,
Dimensions,
@@ -25,10 +24,10 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -172,42 +171,37 @@ function computePosition({
return { x, y, actualPlacement };
}
interface SharedDropdownContentProps {
collapsable: false;
testID?: string;
style: StyleProp<ViewStyle>;
}
function renderDropdownSurface(input: {
isWebSurface: boolean;
sharedProps: SharedDropdownContentProps;
frameStyle: StyleProp<ViewStyle>;
testID?: string;
surfaceStyle: StyleProp<ViewStyle>;
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
onExited: () => void;
}): ReactElement {
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const body = scrollable ? (
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{content}
</ScrollView>
</FloatingScrollView>
) : (
content
);
if (isWebSurface) {
return <View {...sharedProps}>{body}</View>;
}
return (
<Animated.View
{...sharedProps}
<FloatingSurface
collapsable={false}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
@@ -217,7 +211,7 @@ function renderDropdownSurface(input: {
})}
>
{body}
</Animated.View>
</FloatingSurface>
);
}
@@ -513,7 +507,8 @@ export function DropdownMenuContent({
[],
);
const contentStyle = useMemo(() => {
const surfaceStyle = styles.content;
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -523,7 +518,6 @@ export function DropdownMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -543,14 +537,6 @@ export function DropdownMenuContent({
actualPlacement,
align,
]);
const sharedContentProps = useMemo(
() => ({
collapsable: false as const,
testID,
style: contentStyle,
}),
[testID, contentStyle],
);
const scrollViewportStyle = useMemo(
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize, webScrollbarStyle],
@@ -583,8 +569,9 @@ export function DropdownMenuContent({
/>
{!closing
? renderDropdownSurface({
isWebSurface: isWeb,
sharedProps: sharedContentProps,
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,

View File

@@ -0,0 +1,63 @@
import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react";
import {
ScrollView,
StyleSheet,
type ScrollViewProps,
type StyleProp,
type View,
type ViewStyle,
} from "react-native";
import Animated from "react-native-reanimated";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FloatingSurfaceProps extends Omit<ComponentProps<typeof Animated.View>, "style"> {
frameStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
}
export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function FloatingSurface(
{ frameStyle, style, ...props },
ref,
): ReactElement {
const inlineFrameStyle = useMemo(() => {
const flattened = StyleSheet.flatten(frameStyle);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [frameStyle]);
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
});
export interface FloatingScrollViewProps {
bounces?: boolean;
children: ReactNode;
contentContainerStyle?: StyleProp<ViewStyle>;
keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"];
showsVerticalScrollIndicator?: boolean;
style?: StyleProp<ViewStyle>;
}
export function FloatingScrollView({
bounces,
children,
contentContainerStyle,
keyboardShouldPersistTaps,
showsVerticalScrollIndicator,
style,
}: FloatingScrollViewProps): ReactElement {
const inlineStyle = useMemo(() => {
const flattened = StyleSheet.flatten(style);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [style]);
return (
<ScrollView
bounces={bounces}
contentContainerStyle={contentContainerStyle}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
style={inlineStyle}
>
{children}
</ScrollView>
);
}

View File

@@ -25,9 +25,10 @@ import {
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
@@ -494,19 +495,18 @@ export function TooltipContent({
[],
);
const contentStyle = useMemo(
const frameStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, style, position?.x, position?.y],
[maxWidth, position?.x, position?.y],
);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -519,7 +519,7 @@ export function TooltipContent({
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -527,9 +527,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -544,7 +545,7 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -552,9 +553,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</Pressable>
</Modal>
);

View File

@@ -1,17 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
interface WebPointerStyle {
cursor?: "grab" | "grabbing";
touchAction?: "none";
userSelect?: "none";
transitionProperty?: string;
transitionDuration?: string;
transitionTimingFunction?: string;
}
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
@@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
);
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function useWebDesktopScrollbarMetrics() {
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
@@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() {
};
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
@@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
onPanResponderGrant: (event: GestureResponderEvent) => {
const clientY = readClientY(event);
dragStartOffsetRef.current = normalizedOffsetRef.current;
if (clientY !== null) {
dragStartClientYRef.current = clientY;
}
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
@@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
{
top: 0,
inlineUnistylesStyle({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
@@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({
const handleStyle = useMemo(
() => [
styles.handle,
{
inlineUnistylesStyle({
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
@@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
@@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
top: 0,
},
}));

View File

@@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
@@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
interface Rect {
@@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({
[],
);
const cardStyle = useMemo(
() => [
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -279,7 +276,8 @@ function WorkspaceHoverCardContent({
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
style={cardStyle}
style={styles.card}
frameStyle={frameStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
@@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</>
) : null}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingTop: theme.spacing[2],
width: HOVER_CARD_WIDTH,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,

View File

@@ -17,6 +17,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/git/policy";
@@ -110,18 +111,20 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
(Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled],
[primaryDisabled, theme.colors.surface2],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
(hovered || pressed || open) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
],
[],
[theme.colors.surface2],
);
return (
@@ -230,9 +233,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
@@ -258,9 +258,6 @@ const styles = StyleSheet.create((theme) => ({
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
iconButton: {
width: 32,
height: 32,

View File

@@ -78,6 +78,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
@@ -113,25 +114,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
}
function diffModeTriggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.diffModeTrigger,
Boolean(hovered) && styles.diffModeTriggerHovered,
(pressed || Boolean(open)) && styles.diffModeTriggerPressed,
];
}
function expandAllButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
}
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
@@ -274,7 +256,12 @@ function DiffGutterCell({
style?: StyleProp<ViewStyle>;
}) {
const containerStyle = useMemo(
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
() => [
styles.gutterCell,
lineTypeBackground(type),
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
[type, gutterWidth, style],
);
const textStyle = useMemo(
@@ -544,7 +531,10 @@ function InlineReviewThreadContent({
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
@@ -580,7 +570,11 @@ function InlineReviewGutterSpacer({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
() => [
styles.inlineReviewGutterSpacer,
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
style,
],
[gutterWidth, height, style],
);
if (height === 0) {
@@ -604,10 +598,13 @@ function InlineReviewRow({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
@@ -656,7 +653,10 @@ function SplitDiffColumn({
[showDivider],
);
const linesContainerRowStyle = useMemo(
() => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }],
() => [
styles.linesContainer,
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
],
[scrollWidth],
);
@@ -916,7 +916,10 @@ function DiffFileBody({
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const linesContainerRowStyle = useMemo(
() => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }],
() => [
styles.linesContainer,
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
],
[availableWidth],
);
@@ -1062,7 +1065,7 @@ interface GitDiffPaneProps {
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean },
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
interface DiffLayoutToggleGroupProps {
@@ -1436,14 +1439,30 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.expandAllButton,
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
surfaceColor: string,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
selected && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
(selected || Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
@@ -1494,32 +1513,50 @@ export function GitDiffPane({
handleLayoutChange("split");
}, [handleLayoutChange]);
const controlSurfaceColor = theme.colors.surface2;
const diffModeTriggerStyle = useMemo(
() => buildDiffModeTriggerStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
buildToggleButtonStyle(
changesPreferences.layout === "unified",
[styles.toggleButton, styles.toggleButtonGroupStart],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
buildToggleButtonStyle(
changesPreferences.layout === "split",
[styles.toggleButton, styles.toggleButtonGroupEnd],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const hideWhitespaceToggleStyle = useMemo(
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
() =>
buildToggleButtonStyle(
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
[wrapLines, controlSurfaceColor],
);
const expandAllToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const {
@@ -2046,7 +2083,7 @@ export function GitDiffPane({
allExpanded={allExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllButtonStyle}
expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>

View File

@@ -3,6 +3,7 @@ import "@/test/window-local-storage";
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
@@ -275,7 +276,7 @@ describe("git diff inline review helpers", () => {
viewportWidth: 320,
pinToViewport: true,
}),
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
});
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {

View File

@@ -15,6 +15,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
@@ -391,7 +392,7 @@ export function InlineReviewThread({
() => [
styles.threadContainer,
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
{ minHeight: height },
inlineUnistylesStyle({ minHeight: height }),
],
[viewportWidth, pinToViewport, height],
);
@@ -478,7 +479,8 @@ export function getInlineReviewThreadViewportStyle({
viewportWidth?: number;
pinToViewport: boolean;
}): StyleProp<ViewStyle> {
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
const widthStyle =
viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null;
if (!pinToViewport || !isWeb) {
return widthStyle;
}

View File

@@ -0,0 +1,23 @@
const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
/**
* Forces a style object through Unistyles' inline/animated-style lane.
*
* Unistyles web sends ordinary style objects to the CSS registry. Styles that
* look like animated styles stay in React Native's style array instead. Use
* this only for high-churn values, such as measured dimensions, drag
* transforms, and pressed/hovered state.
*/
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
return style;
}
Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
value: {},
enumerable: true,
configurable: true,
});
return style;
}