diff --git a/docs/unistyles.md b/docs/unistyles.md index 93677098d..0c780dc57 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -88,6 +88,8 @@ Do not split a component into plain and Unistyles variants just to handle one hi 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. +Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`. + ## 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. diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index e7ef89401..2de511ca8 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -29,6 +29,7 @@ export function DraggableList({ useDragHandle: _useDragHandle = false, refreshing, onRefresh, + extraData, simultaneousGestureRef, waitFor, onDragBegin: onDragBeginProp, @@ -116,6 +117,7 @@ export function DraggableList({ ListEmptyComponent={ListEmptyComponent} showsVerticalScrollIndicator={showsVerticalScrollIndicator} scrollEnabled={scrollEnabled} + extraData={extraData} simultaneousHandlers={simultaneousHandlers} // Higher activation distance reduces accidental drag capture while nested // lists are inside a scroll container. diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index b2b263261..01b15c640 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -46,6 +46,8 @@ export interface DraggableListProps { onRefresh?: () => void; /** Fill remaining space when content is smaller than container */ contentContainerFlexGrow?: boolean; + /** External row state that should invalidate virtualized native cells. */ + extraData?: unknown; /** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */ simultaneousGestureRef?: MutableRefObject; /** Gesture ref(s) that the list should wait for before handling scroll */ diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index ea1fe107d..971575255 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -135,6 +135,7 @@ export function DraggableList({ showsVerticalScrollIndicator = true, enableDesktopWebScrollbar = false, scrollEnabled = true, + extraData: _extraData, useDragHandle = false, // simultaneousGestureRef is native-only, ignored on web onDragBegin, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 0912cecc3..e4dc1ba52 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -220,6 +220,10 @@ function isProjectSelectedByRoute(input: { ); } +function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { + return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; +} + function selectionForSelectedWorkspace( selected: boolean, workspace: SidebarWorkspaceEntry, @@ -2299,6 +2303,7 @@ function ProjectBlock({ keyExtractor={workspaceKeyExtractor} renderItem={renderWorkspace} onDragEnd={handleWorkspaceDragEnd} + extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)} scrollEnabled={false} useDragHandle nestable={useNestable} @@ -2315,18 +2320,6 @@ function ProjectBlock({ type ProjectBlockProps = Parameters[0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { - const previousActive = isProjectSelectedByRoute({ - selection: previous.activeWorkspaceSelection, - project: previous.project, - serverId: previous.serverId, - enabled: previous.selectionEnabled, - }); - const nextActive = isProjectSelectedByRoute({ - selection: next.activeWorkspaceSelection, - project: next.project, - serverId: next.serverId, - enabled: next.selectionEnabled, - }); return ( previous.project === next.project && previous.collapsed === next.collapsed && @@ -2346,7 +2339,35 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && - previousActive === nextActive + areProjectBlockSelectionsEqual(previous, next) + ); +} + +function areProjectBlockSelectionsEqual( + previous: ProjectBlockProps, + next: ProjectBlockProps, +): boolean { + const previousActive = isProjectSelectedByRoute({ + selection: previous.activeWorkspaceSelection, + project: previous.project, + serverId: previous.serverId, + enabled: previous.selectionEnabled, + }); + const nextActive = isProjectSelectedByRoute({ + selection: next.activeWorkspaceSelection, + project: next.project, + serverId: next.serverId, + enabled: next.selectionEnabled, + }); + if (previousActive !== nextActive) { + return false; + } + if (!previousActive) { + return true; + } + return ( + activeWorkspaceSelectionKey(previous.activeWorkspaceSelection) === + activeWorkspaceSelectionKey(next.activeWorkspaceSelection) ); } @@ -2721,6 +2742,7 @@ function ProjectModeList({ keyExtractor={projectKeyExtractor} renderItem={renderProject} onDragEnd={handleProjectDragEnd} + extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)} scrollEnabled={false} useDragHandle nestable={platformIsNative} diff --git a/packages/app/src/components/ui/floating.tsx b/packages/app/src/components/ui/floating.tsx index 4a689d7be..9c2cc0c55 100644 --- a/packages/app/src/components/ui/floating.tsx +++ b/packages/app/src/components/ui/floating.tsx @@ -21,9 +21,12 @@ export const FloatingSurface = forwardRef(function F ): ReactElement { const inlineFrameStyle = useMemo(() => { const flattened = StyleSheet.flatten(frameStyle); - return flattened ? inlineUnistylesStyle(flattened) : undefined; + return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined; }, [frameStyle]); - const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]); + const surfaceStyle = useMemo( + () => appendStyle(style, inlineFrameStyle), + [inlineFrameStyle, style], + ); return ; }); @@ -46,7 +49,7 @@ export function FloatingScrollView({ }: FloatingScrollViewProps): ReactElement { const inlineStyle = useMemo(() => { const flattened = StyleSheet.flatten(style); - return flattened ? inlineUnistylesStyle(flattened) : undefined; + return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined; }, [style]); return ( @@ -61,3 +64,26 @@ export function FloatingScrollView({ ); } + +function appendStyle( + style: StyleProp, + extraStyle: ViewStyle | undefined, +): StyleProp { + if (!extraStyle) { + return style; + } + if (Array.isArray(style)) { + return [...style, extraStyle]; + } + return [style, extraStyle]; +} + +function stripUnistylesMetadata(style: ViewStyle): ViewStyle { + const cleanStyle: Record = { ...style }; + for (const key of Object.keys(cleanStyle)) { + if (key.startsWith("unistyles_")) { + delete cleanStyle[key]; + } + } + return cleanStyle as ViewStyle; +}