Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions extensions/plugin-basic-ui/src/components/AppScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,11 @@ const AppScreen: React.FC<AppScreenProps> = ({

return null;
},
onSwipeEnd({ swiped }) {
onTransitionEnd({ swiped }) {
if (swiped) {
pop();
pop({
skipActiveState: true
});
}
},
});
Expand Down
249 changes: 179 additions & 70 deletions extensions/react-ui-core/src/useStyleEffectSwipeBack.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { ActivityTransitionState } from "@stackflow/core";
import { useStyleEffect } from "./useStyleEffect";
import { listenOnce, noop } from "./utils";
import { noop } from "./utils";

export const SWIPE_BACK_RATIO_CSS_VAR_NAME = "--stackflow-swipe-back-ratio";

const SPRING_STIFFNESS = 400;
const DAMPING_COEFFICIENT = 20;
const VELOCITY_THRESHOLD = 800;
const MASS = 1;
const DT = 1000 / 60;

export function useStyleEffectSwipeBack({
dimRef,
edgeRef,
Expand Down Expand Up @@ -53,6 +59,9 @@ export function useStyleEffectSwipeBack({
let x0: number | null = null;
let t0: number | null = null;
let x: number | null = null;
let lastX: number | null = null;
let lastT: number | null = null;
let velocity = 0;

let cachedRefs: Array<{
style: {
Expand All @@ -70,6 +79,9 @@ export function useStyleEffectSwipeBack({
x0 = null;
t0 = null;
x = null;
lastX = null;
lastT = null;
velocity = 0;
cachedRefs = [];
};

Expand Down Expand Up @@ -123,87 +135,166 @@ export function useStyleEffectSwipeBack({
}
}

function resetActivity({ swiped }: { swiped: boolean }): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
$dim.style.opacity = `${swiped ? 0 : 1}`;
$dim.style.transition = transitionDuration;
function cleanStyles({ swiped }: { swiped: boolean }) {
const _cachedRefs = [...cachedRefs];

$dim.style.opacity = "";
$paper.style.overflowY = "";
$paper.style.transform = "";
$paper.style.transition = "";

if (moveAppBarTogether && $appBarRef) {
$appBarRef.style.overflowY = "";
$appBarRef.style.transform = "";
$appBarRef.style.transition = "";
}

$appBarRef?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME);

refs.forEach((ref, i) => {
if (!ref.current) return;
const _cachedRef = _cachedRefs[i];

if (swiped) {
ref.current.style.transition = "";
ref.current.style.transform = "";
if (ref.current.parentElement) {
ref.current.parentElement.style.display = "";
}
} else if (_cachedRef) {
ref.current.style.transition = _cachedRef.style.transition;
ref.current.style.transform = _cachedRef.style.transform;
if (ref.current.parentElement && _cachedRef.parentElement) {
ref.current.parentElement.style.display = _cachedRef.parentElement.style.display;
}
}

ref.current.parentElement?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME);
});
}

$paper.style.overflowY = "hidden";
$paper.style.transform = `translateX(${swiped ? "100%" : "0"})`;
$paper.style.transition = transitionDuration;
function resetActivity({ swiped, initialVelocity }: { swiped: boolean, initialVelocity: number }): Promise<void> {
return new Promise((resolve) => {
if (!swiped) {
requestAnimationFrame(() => {
$dim.style.opacity = "0";
$dim.style.transition = "200ms";

if (moveAppBarTogether && $appBarRef) {
$appBarRef.style.overflowY = "hidden";
$appBarRef.style.transform = `translateX(${swiped ? "100%" : "0"})`;
$appBarRef.style.transition = transitionDuration;
}
$paper.style.overflowY = "hidden";
$paper.style.transform = "translate3d(0, 0, 0)";
$paper.style.transition = "200ms";

refs.forEach((ref) => {
if (!ref.current) {
return;
if (moveAppBarTogether && $appBarRef) {
$appBarRef.style.overflowY = "hidden";
$appBarRef.style.transform = "translate3d(0, 0, 0)";
$appBarRef.style.transition = "200ms";
}

ref.current.style.transition = transitionDuration;
ref.current.style.transform = `translate3d(${
swiped ? "0" : `-${offset / 16}rem`
}, 0, 0)`;
refs.forEach((ref, i) => {
if (!ref.current) return;
if (cachedRefs) {
ref.current.style.transition = "transform 0.2s ease-out";
ref.current.style.transform = cachedRefs[i].style.transform;
}
});
Comment on lines +193 to +199
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Bug: Incorrect truthy check on array instead of element.

cachedRefs is always truthy since it's initialized as []. The intent is to check if the element at index i exists before accessing its properties. Without this fix, accessing cachedRefs[i].style.transform when cachedRefs[i] is undefined will throw a TypeError.

🔎 Proposed fix
               refs.forEach((ref, i) => {
                 if (!ref.current) return;
-                if (cachedRefs) {
+                if (cachedRefs[i]) {
                   ref.current.style.transition = "transform 0.2s ease-out";
                   ref.current.style.transform = cachedRefs[i].style.transform;
                 }
               });
📝 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
refs.forEach((ref, i) => {
if (!ref.current) return;
if (cachedRefs) {
ref.current.style.transition = "transform 0.2s ease-out";
ref.current.style.transform = cachedRefs[i].style.transform;
}
});
refs.forEach((ref, i) => {
if (!ref.current) return;
if (cachedRefs[i]) {
ref.current.style.transition = "transform 0.2s ease-out";
ref.current.style.transform = cachedRefs[i].style.transform;
}
});
🤖 Prompt for AI Agents
In extensions/react-ui-core/src/useStyleEffectSwipeBack.ts around lines 193 to
199, the code checks the array cachedRefs instead of the specific element,
causing a TypeError when cachedRefs[i] is undefined; change the condition to
verify cachedRefs[i] (e.g., if (cachedRefs && cachedRefs[i])) before accessing
cachedRefs[i].style.transform so you only read properties when that element
exists and avoid runtime exceptions.


setTimeout(() => {
resolve();
}, 200);
});

const _cachedRefs = [...cachedRefs];
return;
}

resolve();
let currX = x || 0;
const targetX = swiped ? $paper.clientWidth : 0;
let currVelocity = initialVelocity;

listenOnce($paper, ["transitionend", "transitioncancel"], () => {
const _swiped =
swiped ||
getActivityTransitionState() === "exit-active" ||
getActivityTransitionState() === "exit-done";
let prevX = currX;

$dim.style.opacity = "";
$paper.style.overflowY = "";
$paper.style.transform = "";
let lastTimeStamp: DOMHighResTimeStamp | null = null;
let accumulateTimeStamp = 0;

if (moveAppBarTogether && $appBarRef) {
$appBarRef.style.overflowY = "";
$appBarRef.style.transform = "";
$appBarRef.style.removeProperty("transition");
}
let animationId: number | null = null;

$appBarRef?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME);
function updatePhysicalQuantity() {
const displacement = currX - targetX;
const springForce = -SPRING_STIFFNESS * displacement;
const dampingForce = -DAMPING_COEFFICIENT * currVelocity;
const totalForce = springForce + dampingForce;
const acceleration = totalForce / MASS;

refs.forEach((ref, i) => {
if (!ref.current) {
return;
}
currVelocity += acceleration * (DT / 1000);
currX += currVelocity * (DT / 1000);
}

function animateSwipeBackSuccess(timeStamp: DOMHighResTimeStamp) {
if (!animationId) return;

const _cachedRef = _cachedRefs[i];
if (!lastTimeStamp) {
lastTimeStamp = timeStamp;
updatePhysicalQuantity();
}

if (_swiped) {
ref.current.style.transition = "";
ref.current.style.transform = "";
const timeElapsed = timeStamp - lastTimeStamp;
lastTimeStamp = timeStamp;
accumulateTimeStamp += timeElapsed;

if (ref.current.parentElement) {
ref.current.parentElement.style.display = "";
}
} else if (_cachedRef) {
ref.current.style.transition = _cachedRef.style.transition;
ref.current.style.transform = _cachedRef.style.transform;
while (accumulateTimeStamp >= DT) {
prevX = currX;
updatePhysicalQuantity();
accumulateTimeStamp -= DT;
}

if (ref.current.parentElement && _cachedRef.parentElement) {
ref.current.parentElement.style.display =
_cachedRef.parentElement.style.display;
}
}
const alpha = accumulateTimeStamp / DT;
const renderX = prevX + (currX - prevX) * alpha;
const ratio = renderX / $paper.clientWidth;

ref.current.parentElement?.style.removeProperty(
SWIPE_BACK_RATIO_CSS_VAR_NAME,
);
});
renderComponent(renderX, ratio);

onTransitionEnd?.({ swiped });
if (targetX - currX < 10) {
renderComponent(targetX, 100);
resolve();
} else {
animationId = requestAnimationFrame(animateSwipeBackSuccess);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function renderComponent(dx: number, ratio: number) {
const clampedRatio = Math.max(0, Math.min(1, ratio));

$dim.style.opacity = `${1 - clampedRatio}`;
$dim.style.transition = "0s";

$paper.style.overflowY = "hidden";
$paper.style.transform = `translate3d(${dx}px, 0, 0)`;
$paper.style.transition = "0s";

if (moveAppBarTogether && $appBarRef) {
$appBarRef.style.overflowY = "hidden";
$appBarRef.style.transform = `translate3d(${dx}px, 0, 0)`;
$appBarRef.style.transition = "0s";
}

refs.forEach((ref) => {
if (!ref.current) return;

const backgroundOffset = -1 * (1 - clampedRatio) * offset;
ref.current.style.transform = `translate3d(${backgroundOffset}px, 0, 0)`;
ref.current.style.transition = "0s";

if (ref.current.parentElement?.style.display === "none") {
ref.current.parentElement.style.display = "block";
}

ref.current.parentElement?.style.setProperty(
SWIPE_BACK_RATIO_CSS_VAR_NAME,
String(clampedRatio),
);
});
});
}

animationId = requestAnimationFrame(animateSwipeBackSuccess);
});
}

Expand All @@ -212,8 +303,9 @@ export function useStyleEffectSwipeBack({

activeElement?.blur?.();

x0 = x = e.touches[0].clientX;
t0 = Date.now();
x0 = x = lastX = e.touches[0].clientX;
t0 = lastT = Date.now();
velocity = 0;

cachedRefs = refs.map((ref) => {
if (!ref.current) {
Expand Down Expand Up @@ -244,18 +336,28 @@ export function useStyleEffectSwipeBack({
};

const onTouchMove = (e: TouchEvent) => {
if (!x0) {
if (!x0 || !lastX || !lastT) {
resetState();
return;
}

const currTime = Date.now();
x = e.touches[0].clientX;

const dt = (currTime - lastT) / 1000;
if (dt > 0) {
const instantVelocity = (x - lastX) / dt;
velocity = velocity * 0.7 + instantVelocity * 0.3;
}

const dx = x - x0;
const ratio = dx / $paper.clientWidth;

moveActivity({ dx, ratio });
onSwipeMove?.({ dx, ratio });

lastX = x;
lastT = currTime;
};

const onTouchEnd = () => {
Expand All @@ -264,14 +366,21 @@ export function useStyleEffectSwipeBack({
return;
}

const t = Date.now();
const v = (x - x0) / (t - t0);
const swiped = v > 1 || x / $paper.clientWidth > 0.4;
const displacement = x - x0;
const ratio = displacement / $paper.clientWidth;

const swiped = (velocity > VELOCITY_THRESHOLD && displacement > 0) || ratio > 0.4;

onSwipeEnd?.({ swiped });
onSwipeEnd?.({ swiped })

Promise.resolve()
.then(() => resetActivity({ swiped }))
.then(() => resetActivity({ swiped, initialVelocity: velocity }))
.then(() => onTransitionEnd?.({ swiped }))
// wait for unmount
.then(() => new Promise(
resolve => requestAnimationFrame(() => resolve(undefined))
))
.then(() => cleanStyles({ swiped }))
.then(() => resetState());
};

Expand Down
13 changes: 7 additions & 6 deletions integrations/react/src/stable/useActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export type UseActionsOutputType<T extends BaseActivities> = {
* Remove top activity
*/
pop(): void;
pop(options: { animate?: boolean }): void;
pop(count: number, options?: { animate?: boolean }): void;
pop(options: { animate?: boolean, skipActiveState?: boolean }): void;
pop(count: number, options: { animate?: boolean }): void;
};

export function useActions<
Expand Down Expand Up @@ -106,11 +106,11 @@ export function useActions<
};
},
pop(
count?: number | { animate?: boolean } | undefined,
count?: number | { animate?: boolean, skipActiveState?: boolean } | undefined,
options?: { animate?: boolean } | undefined,
) {
let _count = 1;
let _options: { animate?: boolean } = {};
let _options: { animate?: boolean, skipActiveState?: boolean } = {};

if (typeof count === "object") {
_options = {
Expand All @@ -123,13 +123,14 @@ export function useActions<
if (options) {
_options = {
...options,
skipActiveState: false,
};
}

for (let i = 0; i < _count; i += 1) {
coreActions?.pop({
skipExitActiveState:
i === 0 ? parseActionOptions(_options).skipActiveState : true,
skipExitActiveState: _options.skipActiveState ||
(i === 0 ? parseActionOptions(_options).skipActiveState : true),
});
}
},
Expand Down