Skip to content
Open
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
14 changes: 11 additions & 3 deletions docs/registry/ui/app-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { PullToRefreshRoot, PullToRefreshContent, PullToRefreshIndicator } from "./pull-to-refresh";
import { AppScreen as SeedAppScreen } from "@seed-design/stackflow";
import { useActions, useActivity } from "@stackflow/react";
import { forwardRef } from "react";
import { forwardRef, type CSSProperties } from "react";

export interface AppScreenProps extends SeedAppScreen.RootProps {
preventSwipeBack?: boolean;
Expand Down Expand Up @@ -43,12 +43,18 @@ export interface AppScreenContentProps extends SeedAppScreen.LayerProps {
onPtrRefresh?: () => Promise<void>;
}

const swipeBackBoundaryStyle = {
"--swipe-back-displacement": "initial",
"--swipe-back-displacement-ratio": "initial",
"--swipe-back-target": "initial",
} as CSSProperties;

export const AppScreenContent = forwardRef<HTMLDivElement, AppScreenContentProps>(
({ children, ptr, onPtrReady, onPtrRefresh, ...otherProps }, ref) => {
if (!ptr) {
return (
<SeedAppScreen.Layer ref={ref} {...otherProps}>
{children}
<div style={swipeBackBoundaryStyle}>{children}</div>
</SeedAppScreen.Layer>
);
}
Expand All @@ -57,7 +63,9 @@ export const AppScreenContent = forwardRef<HTMLDivElement, AppScreenContentProps
<PullToRefreshRoot asChild onPtrReady={onPtrReady} onPtrRefresh={onPtrRefresh}>
<SeedAppScreen.Layer ref={ref} {...otherProps}>
<PullToRefreshIndicator />
<PullToRefreshContent asChild>{children}</PullToRefreshContent>
<PullToRefreshContent asChild>
<div style={swipeBackBoundaryStyle}>{children}</div>
</PullToRefreshContent>
</SeedAppScreen.Layer>
</PullToRefreshRoot>
);
Expand Down
14 changes: 11 additions & 3 deletions examples/stackflow-spa/src/seed-design/ui/app-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PullToRefreshRoot, PullToRefreshContent, PullToRefreshIndicator } from "./pull-to-refresh";
import { AppScreen as SeedAppScreen } from "@seed-design/stackflow";
import { useActions, useActivity } from "@stackflow/react";
import { forwardRef } from "react";
import { forwardRef, type CSSProperties } from "react";

export interface AppScreenProps extends SeedAppScreen.RootProps {
preventSwipeBack?: boolean;
Expand Down Expand Up @@ -41,12 +41,18 @@ export interface AppScreenContentProps extends SeedAppScreen.LayerProps {
onPtrRefresh?: () => Promise<void>;
}

const swipeBackBoundaryStyle = {
"--swipe-back-displacement": "initial",
"--swipe-back-displacement-ratio": "initial",
"--swipe-back-target": "initial",
} as CSSProperties;

export const AppScreenContent = forwardRef<HTMLDivElement, AppScreenContentProps>(
({ children, ptr, onPtrReady, onPtrRefresh, ...otherProps }, ref) => {
if (!ptr) {
return (
<SeedAppScreen.Layer ref={ref} {...otherProps}>
{children}
<div style={swipeBackBoundaryStyle}>{children}</div>
</SeedAppScreen.Layer>
);
}
Expand All @@ -55,7 +61,9 @@ export const AppScreenContent = forwardRef<HTMLDivElement, AppScreenContentProps
<PullToRefreshRoot asChild onPtrReady={onPtrReady} onPtrRefresh={onPtrRefresh}>
<SeedAppScreen.Layer ref={ref} {...otherProps}>
<PullToRefreshIndicator />
<PullToRefreshContent asChild>{children}</PullToRefreshContent>
<PullToRefreshContent asChild>
<div style={swipeBackBoundaryStyle}>{children}</div>
</PullToRefreshContent>
</SeedAppScreen.Layer>
</PullToRefreshRoot>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
import { useCallback, useMemo, useRef, useState } from "react";
import { useStack } from "@stackflow/react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTopActivity } from "../private/useTopActivity";

export type SwipeBackState = "idle" | "swiping" | "canceling" | "completing";
Expand Down Expand Up @@ -46,6 +46,7 @@ export interface MoveSwipeBackProps {
export interface EndSwipeBackProps {}

export function useGlobalInteraction() {
const stack = useStack();
const [swipeBackState, setSwipeBackState] = useState<SwipeBackState>("idle");

const swipeBackContextRef = useRef<SwipeBackContext>({
Expand All @@ -56,103 +57,159 @@ export function useGlobalInteraction() {
velocity: 0,
});
const stackRef = useRef<HTMLDivElement>(null);
const swipeBackTargetsRef = useRef<HTMLElement[]>([]);

const setSwipeBackContext = useCallback((ctx: SwipeBackContext) => {
swipeBackContextRef.current = ctx;
stackRef.current?.style.setProperty(
"--swipe-back-displacement",
`${ctx.displacement.toString()}px`,
);
stackRef.current?.style.setProperty(
"--swipe-back-displacement-ratio",
ctx.displacementRatio.toString(),
);
const resetSwipeBackVars = useCallback((element: HTMLElement) => {
element.style.removeProperty("--swipe-back-displacement");
element.style.removeProperty("--swipe-back-displacement-ratio");
element.style.removeProperty("--swipe-back-target");
}, []);

const setSwipeBackVar = useCallback((name: string, value: string) => {
swipeBackTargetsRef.current.forEach((element) => {
element.style.setProperty(name, value);
});
}, []);

const applySwipeBackContext = useCallback(
(ctx: SwipeBackContext) => {
setSwipeBackVar("--swipe-back-displacement", `${ctx.displacement.toString()}px`);
setSwipeBackVar("--swipe-back-displacement-ratio", ctx.displacementRatio.toString());
},
[setSwipeBackVar],
);

const activities = stack.activities;

const updateSwipeBackTargets = useCallback(
(nextActivities: typeof activities) => {
const stackElement = stackRef.current;
if (!stackElement) {
swipeBackTargetsRef.current.forEach(resetSwipeBackVars);
swipeBackTargetsRef.current = [];
return;
}

const topIndex = nextActivities.findIndex((activity) => activity.isTop);
const targets: HTMLElement[] = [];

if (topIndex >= 0) {
const topId = nextActivities[topIndex].id;
const topElement = stackElement.querySelector<HTMLElement>(`[data-activity-id="${topId}"]`);

if (topElement?.dataset["activityType"] === "full-screen") {
targets.push(topElement);
}

for (let index = topIndex - 1; index >= 0; index -= 1) {
const behindElement = stackElement.querySelector<HTMLElement>(
`[data-activity-id="${nextActivities[index].id}"]`,
);

if (behindElement?.dataset["activityType"] === "full-screen") {
targets.push(behindElement);
break;
}
}
}

const previousTargets = swipeBackTargetsRef.current;
previousTargets.forEach((previousTarget) => {
if (!targets.includes(previousTarget)) {
resetSwipeBackVars(previousTarget);
}
});

swipeBackTargetsRef.current = targets;
applySwipeBackContext(swipeBackContextRef.current);
},
[applySwipeBackContext, resetSwipeBackVars],
);

useLayoutEffect(() => {
updateSwipeBackTargets(activities);
}, [activities, updateSwipeBackTargets]);
Comment on lines +129 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

useLayoutEffect에 cleanup 함수가 없습니다.

컴포넌트 언마운트 시 타겟 엘리먼트에 설정된 CSS 변수들(--swipe-back-displacement, --swipe-back-displacement-ratio, --swipe-back-target)이 정리되지 않습니다.

🧹 cleanup 함수 추가 제안
 useLayoutEffect(() => {
   updateSwipeBackTargets(activities);
+  return () => {
+    swipeBackTargetsRef.current.forEach(resetSwipeBackVars);
+    swipeBackTargetsRef.current = [];
+  };
 }, [activities, updateSwipeBackTargets]);
🤖 Prompt for AI Agents
In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`
around lines 129 - 131, useLayoutEffect that calls
updateSwipeBackTargets(activities) lacks a cleanup; add a return cleanup
function in the useLayoutEffect inside useGlobalInteraction that resets the CSS
variables (--swipe-back-displacement, --swipe-back-displacement-ratio,
--swipe-back-target) on the elements previously targeted by
updateSwipeBackTargets (use the same activities or stored targets) when the
component unmounts or activities change; implement by capturing the list of
target elements (from activities or from updateSwipeBackTargets return value)
and in the cleanup loop remove or set those CSS custom properties to ''/null so
no stale styles remain.


const setSwipeBackContext = useCallback(
(ctx: SwipeBackContext) => {
swipeBackContextRef.current = ctx;
applySwipeBackContext(ctx);
},
[applySwipeBackContext],
);

const getSwipeBackEvents = useCallback(
(props: SwipeBackProps) => {
const {
swipeBackDisplacementRatioThreshold: displacementRatioThreshold = 0.4,
swipeBackVelocityThreshold: velocityThreshold = 1,
onSwipeBackStart,
onSwipeBackMove,
onSwipeBackEnd,
} = props;
const onSwipeStart = useCallbackRef(props.onSwipeBackStart);
const onSwipeMove = useCallbackRef(props.onSwipeBackMove);
const onSwipeEnd = useCallbackRef(props.onSwipeBackEnd);

const startSwipeBack = useCallback(
({ x0, t0 }: StartSwipeBackProps) => {
setSwipeBackContext({
x0,
t0,
displacement: 0,
displacementRatio: 0,
velocity: 0,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeStart?.();
},
[onSwipeStart],
);

const moveSwipeBack = useCallback(
({ x, t }: MoveSwipeBackProps) => {
const displacement = x - swipeBackContextRef.current.x0;
const displacementRatio = displacement / window.innerWidth;
const velocity = displacement / (t - swipeBackContextRef.current.t0);
setSwipeBackContext({
...swipeBackContextRef.current,
displacement,
displacementRatio,
velocity,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeMove?.({ displacement, displacementRatio });
},
[onSwipeMove],
);

const endSwipeBack = useCallback(
(_: EndSwipeBackProps) => {
const swiped =
swipeBackContextRef.current.displacementRatio > displacementRatioThreshold ||
swipeBackContextRef.current.velocity > velocityThreshold;

if (swiped) {
stackRef.current?.style.setProperty("--swipe-back-target", "100%");
setSwipeBackState("completing");
} else {
stackRef.current?.style.setProperty("--swipe-back-target", "0");
setSwipeBackState("canceling");
}

onSwipeEnd?.({ swiped });
},
[onSwipeEnd, displacementRatioThreshold, velocityThreshold],
);

const reset = useCallback(() => {
const startSwipeBack = ({ x0, t0 }: StartSwipeBackProps) => {
setSwipeBackContext({
x0,
t0,
displacement: 0,
displacementRatio: 0,
velocity: 0,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeBackStart?.();
};

const moveSwipeBack = ({ x, t }: MoveSwipeBackProps) => {
const displacement = x - swipeBackContextRef.current.x0;
const displacementRatio = displacement / window.innerWidth;
const velocity = displacement / (t - swipeBackContextRef.current.t0);
setSwipeBackContext({
...swipeBackContextRef.current,
displacement,
displacementRatio,
velocity,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeBackMove?.({ displacement, displacementRatio });
};
Comment on lines +163 to +175
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

0으로 나누기 가능성이 있습니다.

tt0가 동일한 타임스탬프일 경우 (터치 이벤트가 매우 빠르게 발생할 때) velocity 계산에서 0으로 나누기가 발생하여 Infinity 또는 NaN이 될 수 있습니다.

🛡️ 0으로 나누기 방어 코드 제안
 const moveSwipeBack = ({ x, t }: MoveSwipeBackProps) => {
   const displacement = x - swipeBackContextRef.current.x0;
   const displacementRatio = displacement / window.innerWidth;
-  const velocity = displacement / (t - swipeBackContextRef.current.t0);
+  const timeDelta = t - swipeBackContextRef.current.t0;
+  const velocity = timeDelta > 0 ? displacement / timeDelta : 0;
   setSwipeBackContext({
     ...swipeBackContextRef.current,
     displacement,
     displacementRatio,
     velocity,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const moveSwipeBack = ({ x, t }: MoveSwipeBackProps) => {
const displacement = x - swipeBackContextRef.current.x0;
const displacementRatio = displacement / window.innerWidth;
const velocity = displacement / (t - swipeBackContextRef.current.t0);
setSwipeBackContext({
...swipeBackContextRef.current,
displacement,
displacementRatio,
velocity,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeBackMove?.({ displacement, displacementRatio });
};
const moveSwipeBack = ({ x, t }: MoveSwipeBackProps) => {
const displacement = x - swipeBackContextRef.current.x0;
const displacementRatio = displacement / window.innerWidth;
const timeDelta = t - swipeBackContextRef.current.t0;
const velocity = timeDelta > 0 ? displacement / timeDelta : 0;
setSwipeBackContext({
...swipeBackContextRef.current,
displacement,
displacementRatio,
velocity,
});
setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
onSwipeBackMove?.({ displacement, displacementRatio });
};
🤖 Prompt for AI Agents
In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`
around lines 163 - 175, The velocity computation in moveSwipeBack can divide by
zero when t === swipeBackContextRef.current.t0; modify moveSwipeBack to compute
a safe delta time (e.g., deltaT = t - t0) and if deltaT is 0 or extremely small,
use a fallback (0 or a tiny epsilon) before calculating velocity, then update
setSwipeBackContext with the guarded velocity and keep the rest of the logic
(references: moveSwipeBack, swipeBackContextRef.current.t0, setSwipeBackContext,
setSwipeBackState, onSwipeBackMove).


const endSwipeBack = (_: EndSwipeBackProps) => {
const swiped =
swipeBackContextRef.current.displacementRatio > displacementRatioThreshold ||
swipeBackContextRef.current.velocity > velocityThreshold;

if (swiped) {
setSwipeBackVar("--swipe-back-target", "100%");
setSwipeBackState("completing");
} else {
setSwipeBackVar("--swipe-back-target", "0");
setSwipeBackState("canceling");
}

onSwipeBackEnd?.({ swiped });
};

const reset = () => {
setSwipeBackContext({
x0: 0,
t0: 0,
displacement: 0,
displacementRatio: 0,
velocity: 0,
});
stackRef.current?.style.setProperty("--swipe-back-target", "0");
setSwipeBackVar("--swipe-back-target", "0");
setSwipeBackState("idle");
}, []);

return useMemo(
() => ({
startSwipeBack,
moveSwipeBack,
endSwipeBack,
reset,
}),
[startSwipeBack, moveSwipeBack, endSwipeBack, reset],
);
};

return {
startSwipeBack,
moveSwipeBack,
endSwipeBack,
reset,
};
},
[setSwipeBackContext],
[setSwipeBackContext, setSwipeBackVar],
);

const topActivity = useTopActivity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,31 @@ export interface UseSwipeBackProps extends SwipeBackProps {}

export function useSwipeBack(props: UseSwipeBackProps) {
const globalInteraction = useGlobalInteractionContext();
const events = globalInteraction.getSwipeBackEvents(props);
const {
swipeBackDisplacementRatioThreshold,
swipeBackVelocityThreshold,
onSwipeBackStart,
onSwipeBackMove,
onSwipeBackEnd,
} = props;
const events = useMemo(
() =>
globalInteraction.getSwipeBackEvents({
swipeBackDisplacementRatioThreshold,
swipeBackVelocityThreshold,
onSwipeBackStart,
onSwipeBackMove,
onSwipeBackEnd,
}),
[
globalInteraction,
swipeBackDisplacementRatioThreshold,
swipeBackVelocityThreshold,
onSwipeBackStart,
onSwipeBackMove,
onSwipeBackEnd,
],
);

useEffect(() => {
return () => {
Expand Down