Skip to content

Commit d3359ee

Browse files
fix(chatgpt-exchange): nest the caret scroll so its rebuild can be undone
`tl.to()` returns the TIMELINE, not the tween it created, so `caretScrollTweens` held 49 references to the master timeline. The fonts-ready rebuild then called `.kill()` on the master timeline 49 times and re-added its keyframes on top of the ones it meant to replace. Measured with an overflowing prompt: master timeline detached from the global timeline (tl.parent -> null) scroll tweens on #cge-typed: 49 before the rebuild, 98 after 49 timestamps left with two competing tweens on the same property GSAP's `Animation.kill()` interrupts and detaches rather than killing children, so the composition kept rendering under an explicit seek -- which is why the packaged-defaults SSIM check passed and never saw this. It is still wrong: the timeline is off the ticker, and the stale keyframes were measured against the fallback font. Only an overflowing prompt reaches it, i.e. exactly the remix the scroll exists for. The packaged prompt emits no steps, so nothing is created and nothing is killed. Now one nested child timeline holds the steps: a real object that can be killed and replaced, and killing it cannot reach `tl`. Children sit at the same absolute times and the nest is added at 0, so the motion is unchanged. Verified: overflowing prompt holds at 49 scroll tweens across the rebuild with no duplicated timestamps and the timeline stays attached. Packaged defaults against the pre-feature baseline -- SSIM 0.999998 over 894 frames, 13 frames below 0.9999 against a 249-frame noise floor for this template, duration 14.9s unchanged. Found by Magi in review of #3562. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e299a98 commit d3359ee

1 file changed

Lines changed: 22 additions & 10 deletions

File tree

registry/blocks/chatgpt-exchange/chatgpt-exchange.html

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,15 +1385,23 @@
13851385
// composition. The caret has 35px of clearance to the button, which is the real boundary.
13861386
const compStopEl = composer.querySelector("#cge-blue-btn");
13871387
const CARET_PAD = 8;
1388-
let caretScrollTweens = [];
1388+
// The rebuild below has to discard exactly what the previous pass added, and `tl.to()`
1389+
// returns the TIMELINE, not the tween it just created -- so collecting those return values
1390+
// and killing them on the second pass kills the master timeline, and the composition stops
1391+
// dead. It only bites when there are keyframes to discard, i.e. an overflowing prompt: the
1392+
// exact remix this scroll exists for, and why a packaged-defaults render never saw it.
1393+
// One nested child timeline is a real object that can be killed and replaced, and killing
1394+
// it cannot reach `tl`.
1395+
let caretTl = null;
13891396
const layoutCaretScroll = () => {
1390-
const hadTweens = caretScrollTweens.length > 0;
1391-
caretScrollTweens.forEach((tw) => tw.kill());
1392-
caretScrollTweens = [];
1393-
// Only ever touch typedEl when there is scrolling to do. A zero translate — or even a
1394-
// clearProps on an untouched element — stamps and removes a transform, which changes how
1395-
// the text rasterizes and makes the packaged render differ from before for no reason.
1396-
if (hadTweens) gsap.set(typedEl, { clearProps: "transform" });
1397+
if (caretTl) {
1398+
caretTl.kill();
1399+
caretTl = null;
1400+
// Only ever touch typedEl when there was scrolling to undo. A zero translate — or even
1401+
// a clearProps on an untouched element — stamps and removes a transform, which changes
1402+
// how the text rasterizes and makes the packaged render differ for no reason.
1403+
gsap.set(typedEl, { clearProps: "transform" });
1404+
}
13971405
if (!compTextEl || !compStopEl) return;
13981406
// Rects live in the scaled .screen space while offsets are layout px, so convert with the
13991407
// element's own ratio rather than reading --scale -- this then survives a change to it.
@@ -1415,11 +1423,15 @@
14151423
}
14161424
});
14171425
if (!steps.length) return;
1426+
// Children sit at the same absolute times as before, and the nest is added at 0, so the
1427+
// nested timeline's local clock is the master's -- the motion is unchanged.
1428+
caretTl = gsap.timeline();
14181429
steps.forEach(([at, x]) => {
1419-
caretScrollTweens.push(tl.to(typedEl, { x, duration: 0.08, ease: "none" }, at));
1430+
caretTl.to(typedEl, { x, duration: 0.08, ease: "none" }, at);
14201431
});
14211432
// Back to the start when the composer empties, so the collapse animates from x=0.
1422-
caretScrollTweens.push(tl.to(typedEl, { x: 0, duration: 0.01 }, tGo));
1433+
caretTl.to(typedEl, { x: 0, duration: 0.01 }, tGo);
1434+
tl.add(caretTl, 0);
14231435
};
14241436
layoutCaretScroll();
14251437
// This composition builds its timeline once, so the first pass measures whatever font was

0 commit comments

Comments
 (0)