From 2556924092770e08c86c7eee2199c43d0b294094 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 12:32:47 +0200 Subject: [PATCH] fix(image-bank): clamp flow grid scroll ranges to valid keyframe offsets On WebKit with ScrollTimeline support (macOS 26), motion hands useTransform's input range to WAAPI as keyframe offsets. FlowScrollCell computed ranges that dip below 0 on the first rows, exceed 1 on the last and aren't sorted, so element.animate threw a TypeError. With no error boundary that unmounted the whole app, leaving a black window whenever the image bank showed two or more rows of screenshots. Clamp the range into [0, 1] and keep it non-decreasing. --- src/components/effects/FlowScrollGrid.tsx | 6 ++++-- src/components/effects/keyframeOffsets.test.ts | 14 ++++++++++++++ src/components/effects/keyframeOffsets.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/components/effects/keyframeOffsets.test.ts create mode 100644 src/components/effects/keyframeOffsets.ts diff --git a/src/components/effects/FlowScrollGrid.tsx b/src/components/effects/FlowScrollGrid.tsx index 1292ec3..806fca9 100644 --- a/src/components/effects/FlowScrollGrid.tsx +++ b/src/components/effects/FlowScrollGrid.tsx @@ -4,6 +4,8 @@ import { motion, type MotionValue, useScroll, useTransform } from "motion/react"; import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"; +import { toKeyframeOffsets } from "./keyframeOffsets"; + const GAP_PX = 16; function FlowScrollCell({ @@ -33,14 +35,14 @@ function FlowScrollCell({ const exitAnimation = nextRow / totalRows + scrollRangePerRow * 2; const offsetToAdd = (scrollRangePerRow / totalItems) * (currentRow + 2); - const range = [ + const range = toKeyframeOffsets([ 0, entryAnimation - offsetToAdd, currPosition - offsetToAdd, currPosition - offsetToAdd, exitAnimation - offsetToAdd, 1, - ]; + ]); const scale = useTransform(scrollYProgress, range, [0.5, 0.5, 1, 1, 0.5, 0.5]); const isLeft = index % ITEMS_PER_ROW === 0; diff --git a/src/components/effects/keyframeOffsets.test.ts b/src/components/effects/keyframeOffsets.test.ts new file mode 100644 index 0000000..a0a2af5 --- /dev/null +++ b/src/components/effects/keyframeOffsets.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { toKeyframeOffsets } from "./keyframeOffsets"; + +describe("toKeyframeOffsets", () => { + it("clamps into [0, 1] and never decreases", () => { + expect(toKeyframeOffsets([0, -0.6, -0.1, -0.1, 0.9, 1])).toEqual([0, 0, 0, 0, 0.9, 1]); + expect(toKeyframeOffsets([0, 0.2, 0.5, 0.5, 1.4, 1])).toEqual([0, 0.2, 0.5, 0.5, 1, 1]); + }); + + it("leaves a valid range untouched", () => { + expect(toKeyframeOffsets([0, 0.1, 0.3, 0.3, 0.7, 1])).toEqual([0, 0.1, 0.3, 0.3, 0.7, 1]); + }); +}); diff --git a/src/components/effects/keyframeOffsets.ts b/src/components/effects/keyframeOffsets.ts new file mode 100644 index 0000000..e326d4d --- /dev/null +++ b/src/components/effects/keyframeOffsets.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +/** + * Clamp a scroll range into [0, 1] and make it non-decreasing. On WebKit with + * ScrollTimeline support, motion hands useTransform's input range to WAAPI as + * keyframe offsets, which throw a TypeError when out of range or unsorted — + * and that error unmounts the whole app. + */ +export function toKeyframeOffsets(range: number[]): number[] { + let floor = 0; + return range.map((v) => { + floor = Math.max(floor, Math.min(1, v)); + return floor; + }); +}