Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/kit/src/components/HyperlinkText/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,37 @@ export function HyperlinkText({
{string}
</SizableText>
),
// Semantic color tags. Deliberately no per-color
// `*TextProps` escape hatch: five more ISizableTextProps would
// bloat an already-wide prop type and drag five more entries
// into the memo deps, while `<text>` + textProps already covers
// a caller that needs an arbitrary color. The tokens are
// theme-aware, so these follow light/dark on their own.
red: ([string]) => (
<SizableText {...basicTextProps} color="$textCritical">
{string}
</SizableText>
),
green: ([string]) => (
<SizableText {...basicTextProps} color="$textSuccess">
{string}
</SizableText>
),
yellow: ([string]) => (
<SizableText {...basicTextProps} color="$textCaution">
{string}
</SizableText>
),
blue: ([string]) => (
<SizableText {...basicTextProps} color="$textInfo">
{string}
</SizableText>
),
grey: ([string]) => (
<SizableText {...basicTextProps} color="$textSubdued">
{string}
</SizableText>
),
text: (chunks) => (
<>
{chunks.map((chunk, index) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ interface IOuterTabPagerViewProps {
earnTabsRef?: React.RefObject<ITabContainerRef | null>;
earnBorrowPagerRef?: React.RefObject<IEarnBorrowPagerViewRef | null>;
pageScrollPosition?: SharedValue<number>;
/** Pages currently rendered (active plus whatever a drag has revealed), so a
host can show a page's content the moment it becomes visible rather than
waiting for the swipe to commit (OK-60300) */
onVisiblePagesChange?: (pages: number[]) => void;
}

// --- Component ---
Expand All @@ -114,6 +118,7 @@ function OuterTabPagerViewComponent({
earnTabsRef,
earnBorrowPagerRef,
pageScrollPosition,
onVisiblePagesChange,
}: IOuterTabPagerViewProps) {
const initialPage = TAB_TO_INDEX[selectedHeaderTab] ?? 0;
const outerPagerRef = useAnimatedRef<PagerView>();
Expand Down Expand Up @@ -155,6 +160,14 @@ function OuterTabPagerViewComponent({
const visitedPagesRef = useRef<Record<number, boolean>>({
[initialPage]: true,
});
// Pages mounted only for the duration of a drag. visitedPages is cumulative
// and has no cleanup path, so marking both neighbors there would permanently
// mount whichever one the swipe never landed on — from Earn that is the
// whole Browser tab, WebView containers and restored tabs included, for a
// user who may never open it. Kept separate so idle can drop it again.
const [dragNeighborPages, setDragNeighborPages] = useState<number[] | null>(
null,
);

// Ref to avoid stale closure in onPageSelected
const selectedHeaderTabRef = useRef(selectedHeaderTab);
Expand Down Expand Up @@ -197,6 +210,23 @@ function OuterTabPagerViewComponent({
setVisitedPages(nextVisited);
}, []);

// Publishing visible pages from the effect below alone is one commit too
// late: the commit that unfreezes a page still carries the host's previous
// visibility, so the page mounts while its body is still display:none —
// exactly the blank first frame OK-60300 is about, just narrower. Calling
// this from the same handler that unfreezes puts both state updates in one
// React batch, so the page appears and paints together. The effect stays as
// the backstop for paths that do not go through a handler; the host setter
// dedupes by content, so the second call is a no-op.
const publishVisiblePages = useCallback(
(indexes: number[]) => {
onVisiblePagesChange?.(
Array.from(new Set(indexes)).toSorted((a, b) => a - b),
);
},
[onVisiblePagesChange],
);

// --- Atom -> PagerView sync (programmatic switching) ---
useEffect(() => {
const index = TAB_TO_INDEX[selectedHeaderTab];
Expand Down Expand Up @@ -229,15 +259,32 @@ function OuterTabPagerViewComponent({
if (state === 'dragging') {
wasUserDragRef.current = true;
setTransitioning(true);
// 'dragging' arrives before the first onPageScroll offset, so the
// direction is still unknown. Mount both neighbors now; the pager has
// already started revealing one of them, and waiting for the
// runOnJS(onPageScroll) round trip is what left a blank page on screen
// for the first frames of the swipe (OK-60300).
const active = currentOuterIndexRef.current;
const neighborhood = [active - 1, active, active + 1].filter(
(index) => index >= 0 && index < INDEX_TO_TAB.length,
);
setDragNeighborPages(neighborhood);
Comment thread
ezailWang marked this conversation as resolved.
publishVisiblePages(neighborhood);
} else if (state === 'settling') {
setTransitioning(true);
} else if (state === 'idle') {
wasUserDragRef.current = false;
setTransitioning(false);
setVisiblePair(null);
// Pin the page the swipe actually landed on before releasing the
// transient pair; handlePageScrollJS and onPageSelected normally do
// this already, but a canceled drag must not unmount where we are.
markPagesVisited([currentOuterIndexRef.current]);
setDragNeighborPages(null);
publishVisiblePages([currentOuterIndexRef.current]);
}
},
[setTransitioning, setVisiblePair],
[markPagesVisited, publishVisiblePages, setTransitioning, setVisiblePair],
);

// JS-thread handler for freeze/unfreeze logic during user-gesture swipes.
Expand All @@ -261,8 +308,9 @@ function OuterTabPagerViewComponent({
}
setVisiblePair([position, nextPosition]);
markPagesVisited([position, nextPosition]);
publishVisiblePages([position, nextPosition]);
},
[markPagesVisited, setVisiblePair],
[markPagesVisited, publishVisiblePages, setVisiblePair],
);

// Worklet-based onPageScroll: updates pageScrollPosition on the UI thread
Expand Down Expand Up @@ -309,6 +357,15 @@ function OuterTabPagerViewComponent({
[markPagesVisited, onPageSelectedBySwipe],
);

// A page renders when it has been properly visited, or while a drag is
// transiently holding it so the finger never reveals an empty page.
const isPageMounted = useCallback(
(pageIndex: number) =>
Boolean(visitedPages[pageIndex]) ||
Boolean(dragNeighborPages?.includes(pageIndex)),
[dragNeighborPages, visitedPages],
);

// Determine which pages should be rendered
const shouldFreezePage = useCallback(
(pageIndex: number) => {
Expand All @@ -318,13 +375,34 @@ function OuterTabPagerViewComponent({
visiblePagePair[0] !== pageIndex && visiblePagePair[1] !== pageIndex
);
}
return activePageIndex !== pageIndex;
// Direction not resolved yet (the 'dragging' window): keep both
// neighbors alive so whichever one the finger reveals already has
// native views. Only lasts for the drag — idle falls back to the
// single active page below.
return Math.abs(activePageIndex - pageIndex) > 1;
}
return activePageIndex !== pageIndex;
},
[activePageIndex, isOuterPageTransitioning, visiblePagePair],
);

// Mirror of shouldFreezePage: the set of pages whose content is actually on
// screen. Hosts use it to reveal a page's body in step with the swipe.
const visiblePagesKey = INDEX_TO_TAB.map((_, index) =>
shouldFreezePage(index) ? '0' : '1',
).join('');
useEffect(() => {
Comment thread
ezailWang marked this conversation as resolved.
if (!onVisiblePagesChange) {
return;
}
onVisiblePagesChange(
visiblePagesKey
.split('')
.map((flag, index) => (flag === '1' ? index : -1))
.filter((index) => index >= 0),
);
}, [onVisiblePagesChange, visiblePagesKey]);

// --- Freeze/unfreeze resync & programmatic page scroll ---
//
// This effect runs AFTER the render commit that unfreezes the target page.
Expand Down Expand Up @@ -371,7 +449,7 @@ function OuterTabPagerViewComponent({

const marketPage = useMemo(
() =>
visitedPages[0] ? (
isPageMounted(0) ? (
<View key="market" style={styles.page}>
<Freeze freeze={shouldFreezePage(0)}>{marketContent}</Freeze>
</View>
Expand All @@ -380,12 +458,12 @@ function OuterTabPagerViewComponent({
<Stack flex={1} />
</View>
),
[visitedPages, shouldFreezePage, marketContent],
[isPageMounted, shouldFreezePage, marketContent],
);

const earnPage = useMemo(
() =>
visitedPages[1] ? (
isPageMounted(1) ? (
<View key="earn" style={styles.page}>
<Freeze freeze={shouldFreezePage(1)}>{earnContent}</Freeze>
</View>
Expand All @@ -394,12 +472,12 @@ function OuterTabPagerViewComponent({
<Stack flex={1} />
</View>
),
[visitedPages, shouldFreezePage, earnContent],
[isPageMounted, shouldFreezePage, earnContent],
);

const browserPage = useMemo(
() =>
visitedPages[2] ? (
isPageMounted(2) ? (
<View key="browser" style={styles.page}>
<Freeze freeze={shouldFreezePage(2)}>{browserContent}</Freeze>
</View>
Expand All @@ -408,7 +486,7 @@ function OuterTabPagerViewComponent({
<Stack flex={1} />
</View>
),
[visitedPages, shouldFreezePage, browserContent],
[isPageMounted, shouldFreezePage, browserContent],
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ function MobileBrowser() {
const [settings] = useSettingsPersistAtom();
const selectedHeaderTab =
settings.selectedBrowserTab || ETranslations.global_browser;
// Pages the outer pager is actually painting. Tracked separately from
// selectedHeaderTab, which only commits after the swipe finishes — the
// gap between the two is what showed a blank page mid-swipe (OK-60300).
const [visibleOuterPages, setVisibleOuterPages] = useState<number[]>([]);
const handleVisiblePagesChange = useCallback((pages: number[]) => {
setVisibleOuterPages((prev) =>
prev.length === pages.length && prev.every((v, i) => v === pages[i])
? prev
: pages,
);
}, []);
const isEarnPageVisible =
selectedHeaderTab === ETranslations.global_earn ||
visibleOuterPages.includes(1);
const exploreTabSwitchTypeRef = useRef<IExploreTabSwitchType>('default');
const hasLoggedExploreTabViewRef = useRef(false);

Expand Down Expand Up @@ -569,6 +583,7 @@ function MobileBrowser() {
marketTabsRef={marketTabsRef}
earnTabsRef={earnTabsRef}
earnBorrowPagerRef={earnBorrowPagerRef}
onVisiblePagesChange={handleVisiblePagesChange}
marketContent={
<MarketHomeWithProvider
isFocused={selectedHeaderTab === ETranslations.global_market}
Expand All @@ -580,6 +595,7 @@ function MobileBrowser() {
<EarnHomeWithProvider
showHeader={false}
showContent={selectedHeaderTab === ETranslations.global_earn}
isVisible={isEarnPageVisible}
defaultTab={earnTab}
tabsRef={earnTabsRef}
useSwipePager={useOuterPager}
Expand Down
Loading
Loading