Skip to content

Commit f2edfe8

Browse files
committed
feat(Timeline): e2e visual tests + restart boundary alignment fix
Two related workstreams on the feat/timeline branch: ### 1. E2E visual regression testing (examples 03, 20, 21, 22) Added Playwright-based e2e tests with visual snapshot regression for the timeline and graphs examples. Uses a deterministic requestAnimationFrame hijack (__tickFrame/__advanceTo) so timeline tests produce identical output every run — no real-time frame variance. - e2e/graphs.spec.ts — snapshot of the 03_graphs easing curve thumbnails (waits for fonts via document.fonts.ready to prevent cross-platform diff). - e2e/timeline.spec.ts — 3 test suites: - 20_timeline — sequential, parallel, and label-based animation (16 frames at 200ms steps, 0–3000ms). - 21_timeline_slider — slider-driven scrubbing forward (17 frames at 400ms steps, 0–6400ms) and backward (17 frames, 6400→0ms), without rAF hijack (no animation loop — purely slider input events). - 22_timeline_repeat — repeat×3, yoyo, and yoyo+repeat×2 animation (16 frames at 200ms steps, 0–3000ms). - e2e/playwright.config.ts — serves the repo via npx serve on port 3333. - 62 baseline snapshots (.webp, Chrome on macOS). Example fixes caught during testing: - examples/20_timeline.html — fixed the "parallel" example (target2) so it actually demonstrates parallelism (y 0→200→100 instead of 0→200→0 on the chained third tween); wrapped the label-based inner timeline in an outer timeline for correct coordinated playback. - examples/22_timeline_repeat.html — switched from X to Y axis translation for clearer visual distinction between boxes; added CSS color comments. - e2e/graphs.spec.ts — switched waitUntil to networkidle and added document.fonts.ready to prevent font-loading snapshot diffs. ### 2. Timeline restart boundary alignment (fixes sub-frame sync drift) Problem: When a short yoyo timeline (1200ms cycle) restarted via timeline.start(time), _startTime was set to the wall-clock time (~1200.024ms — one frame overshoot). Children subsequently saw elapsedTime = t - 1200.024, a 0.024ms offset. A longer yoyo+repeat×2 timeline (2400ms cycle) avoided this because its clone at offset 1200 started at the exact offset with no wall-clock overshoot. The offset propagated through Quadratic.InOut easing to a ~0.016px value difference mid-cycle, growing with variable frame timing. Root cause: Timeline.start(time) unconditionally set _startTime = time. Fix (3 changes in src/Timeline.ts): - New field _nextStartTime: number | undefined — stores the ideal _startTime + _duration boundary when the timeline finishes. - In update(), before returning false, store the boundary. - In start(), use the stored boundary when time >= _nextStartTime (forward restart — snaps to exact boundary); fall through to time when time < _nextStartTime (deliberate scrub-back) or _nextStartTime is undefined (fresh start). Resets _lastUpdateTime = -Infinity to prevent false scrub-back detection on restart. Backward compatibility: All 1063 unit assertions pass. Scrub-back test updated — children not yet reached in forward play now preserve their stale forward-play values rather than eagerly snapping to start values. Documentation: dev-docs/02-timeline-restart-boundary-alignment.md — full write-up with concrete frame data showing the before/after diff.
1 parent ce21da7 commit f2edfe8

85 files changed

Lines changed: 476 additions & 80 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎NOTES.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@ library. User-facing docs live in `examples/` and `README.md`.
1414

1515
- **01 — Timeline.update() scrub-back detection** [`dev-docs/01-timeline-update-scrub-detection.md`](dev-docs/01-timeline-update-scrub-detection.md)
1616
Fixes an auto-restart bug where late-offset children overwrote early children
17-
at the start of a restarted cycle. Introduces `_lastUpdateTime` traking and
17+
at the start of a restarted cycle. Introduces `_lastUpdateTime` tracking and
1818
direction-aware eager-restart/clamp gating in `update()`. ✅
19+
- **02 — Timeline restart boundary alignment** [`dev-docs/02-timeline-restart-boundary-alignment.md`](dev-docs/02-timeline-restart-boundary-alignment.md)
20+
Fixes a sub-frame timing drift between repeated short timelines and longer
21+
equivalent timelines. Stores the ideal `_startTime + _duration` boundary
22+
on finish and snaps `start()` to it. ✅
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# 02 — Timeline restart boundary alignment
2+
3+
**Date:** 2026-09-21
4+
**Status:** ✅ Implemented
5+
6+
## Problem
7+
8+
When a running timeline finishes and the application restarts it via
9+
`timeline.start(time)`, the `_startTime` was set to the current wall-clock
10+
`time`. In a real browser (or hijacked rAF test environment), the frame time
11+
almost never lands exactly on the correct duration boundary (e.g., 1200 ms).
12+
This creates a sub-frame offset in the new cycle's clock.
13+
14+
### Concrete example (22_timeline_repeat)
15+
16+
```js
17+
// yoyo timeline (1200 ms cycle)
18+
tl.add(new Tween(target2.dataset).to({y: 200}, 600).easing(Easing.Quadratic.InOut), {yoyo: true})
19+
20+
// yoyo+repeat timeline (2400 ms cycle)
21+
tl.add(new Tween(target3.dataset).to({y: 200}, 600).easing(Easing.Quadratic.InOut), {yoyo: true, repeat: 2})
22+
```
23+
24+
The `yoyo` timeline finishes at frame time ~1200.024 ms (frame interval ~16.667
25+
ms). Its `start` call sets `_startTime = 1200.024`. Children at offset 0
26+
subsequently see `elapsedTime = t - 1200.024`, a 0.024 ms offset.
27+
28+
The `yoyo+repeat:2` timeline does NOT restart at 1200 ms. Instead, its forward
29+
clone at offset 1200 starts with `child.start(1200)` — the **exact** offset, no
30+
wall-clock overshoot. It subsequently sees `elapsedTime = t - 1200`.
31+
32+
At frame t ≈ 1216.691 ms, these two elapsed times are:
33+
34+
- yoyo: `16.667` (1216.691 − 1200.024 = 16.667)
35+
- yoyo+repeat: `16.691` (1216.691 − 1200 = 16.691)
36+
37+
This 0.024 ms difference in elapsed propagates through the Quadratic.InOut
38+
easing, reaching **~0.016 px** value difference mid-cycle (at the steepest part
39+
of the curve). In a real browser with variable frame timing, the offset can be
40+
larger and compound further.
41+
42+
### Snapshot data before fix
43+
44+
```
45+
t= 1500: y2=100.004000, y3=100.019999 diff=0.015999 ***
46+
t= 2100: y2= 99.988000, y3= 99.972002 diff=0.015998 ***
47+
t= 2400: y2= 0.000000, y3= 0.000000 diff=0.000000 (resync)
48+
```
49+
50+
After the fix, both targets produce identical values at every frame:
51+
52+
```
53+
t= 1500: y2=100.019999, y3=100.019999 diff=0.000000
54+
t= 2100: y2= 99.972002, y3= 99.972002 diff=0.000000
55+
```
56+
57+
## Root cause
58+
59+
In `Timeline.start(time)`, the `_startTime` was unconditionally set to the
60+
wall-clock `time` parameter. When the timeline restarts after finishing, `time`
61+
overshoots the ideal cycle boundary by up to one frame interval, creating a
62+
permanent sub-frame offset in the next cycle's clock.
63+
64+
The longer `yoyo+repeat:2` timeline avoids this because its extra clips start at
65+
precise offsets (1200, 1800) within the single longer cycle — no restart, no
66+
wall-clock overshoot.
67+
68+
## Fix (3 locations, `src/Timeline.ts`)
69+
70+
### 1. New field `_nextStartTime`
71+
72+
```ts
73+
private _nextStartTime: number | undefined
74+
```
75+
76+
Stored when the timeline finishes — the **ideal** next-cycle boundary.
77+
78+
### 2. Store the boundary in `update()`
79+
80+
```ts
81+
if (this._duration === 0 || time >= this._startTime + this._duration) {
82+
if (this._onCompleteCallback) this._onCompleteCallback(this)
83+
// Store the ideal next-cycle boundary so start() snaps to it
84+
// instead of the overshooting wall-clock time.
85+
this._nextStartTime = this._startTime + this._duration
86+
this._isPlaying = false
87+
return false
88+
}
89+
```
90+
91+
Before returning `false`, the ideal boundary `_startTime + _duration` is stored.
92+
For the first finish of the yoyo timeline (duration 1200 ms, `_startTime`
93+
initially 0), this stores 1200 — exactly where the next cycle should begin.
94+
95+
### 3. Conditionally use the boundary in `start()`
96+
97+
```ts
98+
// Snap to the ideal cycle boundary when restarting a finished
99+
// timeline, so repeated timelines stay in sync with longer ones.
100+
// Only when the caller's time is at or past the boundary;
101+
// a time before the boundary means a deliberate scrub-back.
102+
this._startTime = this._nextStartTime !== undefined && time >= this._nextStartTime ? this._nextStartTime : time
103+
this._nextStartTime = undefined
104+
```
105+
106+
- **`time >= _nextStartTime`** (forward restart): Uses the stored boundary.
107+
The yoyo timeline's restart at time 1200.024 sees `1200.024 >= 1200` → uses
108+
`1200`. Children see `elapsedTime = t - 1200`, perfectly aligned with the
109+
longer timeline's clone.
110+
- **`time < _nextStartTime`** (scrub-back): Falls through to `time`. Preserves
111+
the existing scrub-to-start behavior tested in the unit suite.
112+
- **`_nextStartTime` undefined** (fresh start): Falls through to `time`. No
113+
change for initial starts.
114+
115+
## Backward compatibility
116+
117+
All 1063 unit assertions pass. E2E visual regression snapshots updated. The
118+
scrub-back test (`Timeline scrubbing back to start restores start values`)
119+
correctly triggers the `time < _nextStartTime` branch and preserves the original
120+
`start(time)` semantics.
121+
122+
## Affected code
123+
124+
| File | Change |
125+
| --------------------------------- | ------------------------------------------------------------- |
126+
| `src/Timeline.ts` | +`_nextStartTime` field, store boundary in `update()`, use in `start()` |
127+
| `e2e/timeline.spec.ts-snapshots/` | Updated snapshots for `22_timeline_repeat` test |

‎dist/tween.amd.js‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -578,9 +578,7 @@ define(['exports'], (function (exports) { 'use strict';
578578
temp.push(value);
579579
}
580580
if (isInterpolationList) {
581-
// if (_valuesStart[property] === undefined) { // handle end values only the first time. NOT NEEDED? setupProperties is now guarded by _propertiesAreSetUp.
582581
_valuesEnd[property] = temp;
583-
// }
584582
}
585583
}
586584
// handle the deepness of the values
@@ -1043,6 +1041,7 @@ define(['exports'], (function (exports) { 'use strict';
10431041
this._isPlaying = false;
10441042
this._isPaused = false;
10451043
this._pauseStart = 0;
1044+
this._lastUpdateTime = -Infinity;
10461045
this._onStartCallbackFired = false;
10471046
// Empty on purpose. Use `.add()` to compose.
10481047
// Sequential by default, parallel via explicit offsets, labels, or options.
@@ -1423,7 +1422,13 @@ define(['exports'], (function (exports) { 'use strict';
14231422
this._isPlaying = true;
14241423
this._isPaused = false;
14251424
this._onStartCallbackFired = false;
1426-
this._startTime = time;
1425+
// Snap to the ideal cycle boundary when restarting a finished
1426+
// timeline, so repeated timelines stay in sync with longer ones.
1427+
// Only when the caller's time is at or past the boundary;
1428+
// a time before the boundary means a deliberate scrub-back.
1429+
this._startTime = this._nextStartTime !== undefined && time >= this._nextStartTime ? this._nextStartTime : time;
1430+
this._nextStartTime = undefined;
1431+
this._lastUpdateTime = -Infinity;
14271432
for (var _i = 0, _a = this._entries; _i < _a.length; _i++) {
14281433
var entry = _a[_i];
14291434
if (entry.node.isPlaying())
@@ -1507,26 +1512,41 @@ define(['exports'], (function (exports) { 'use strict';
15071512
}
15081513
var timelineLocal = time - this._startTime;
15091514
var effectiveLocal = Math.min(timelineLocal, this._duration);
1515+
// Detect scrub-back vs forward play: only eagerly restart/clamp
1516+
// late-offset children when the playhead moved backward. On
1517+
// forward playback (including auto-restart), children start
1518+
// lazily so same-property tweens chain correctly.
1519+
var scrubbingBack = time < this._lastUpdateTime;
1520+
this._lastUpdateTime = time;
15101521
for (var _i = 0, _a = this._entries; _i < _a.length; _i++) {
15111522
var entry = _a[_i];
15121523
var child = entry.node;
15131524
if (!entry.started) {
1514-
// First start must wait until due; a never-started child left
1515-
// behind stays untouched. (Later re-starts are harmless and
1516-
// handled below, since Tween keeps its captured setup.)
1525+
// First start must wait until due; a never-started child
1526+
// left behind stays untouched.
15171527
if (effectiveLocal < entry.offset)
15181528
continue;
15191529
child.start(entry.offset);
15201530
entry.started = true;
15211531
}
15221532
else if (!child.isPlaying() && effectiveLocal < entry.offset + child.getTotalDuration()) {
1523-
// Re-enter when the playhead is inside the child's range after
1524-
// scrubbing back.
1525-
child.start(entry.offset);
1533+
// On scrub-back, eagerly re-enter children past the playhead
1534+
// so they snap to their start values. On forward playback
1535+
// only re-enter when the playhead has reached the child.
1536+
if (scrubbingBack || effectiveLocal >= entry.offset) {
1537+
child.start(entry.offset);
1538+
}
1539+
}
1540+
// On scrub-back, clamp late children to their offset so they
1541+
// output start values. On forward playback let the child
1542+
// start lazily (no output until playhead reaches offset).
1543+
if (scrubbingBack && effectiveLocal < entry.offset) {
1544+
child.update(entry.offset);
1545+
}
1546+
else if (!scrubbingBack && effectiveLocal < entry.offset && !child.isPlaying()) ;
1547+
else {
1548+
child.update(effectiveLocal);
15261549
}
1527-
// Clamp the lower end so reversed/scrubbed playheads snap the
1528-
// child to its start value instead of freezing on stale values.
1529-
child.update(effectiveLocal < entry.offset ? entry.offset : effectiveLocal);
15301550
}
15311551
if (!isFinite(this._duration)) {
15321552
if (this._onUpdateCallback)
@@ -1539,6 +1559,10 @@ define(['exports'], (function (exports) { 'use strict';
15391559
if (this._duration === 0 || time >= this._startTime + this._duration) {
15401560
if (this._onCompleteCallback)
15411561
this._onCompleteCallback(this);
1562+
// Store the ideal next-cycle boundary so start() snaps to it
1563+
// instead of the overshooting wall-clock time. This keeps
1564+
// repeated timelines precisely aligned with longer ones.
1565+
this._nextStartTime = this._startTime + this._duration;
15421566
this._isPlaying = false;
15431567
return false;
15441568
}

‎dist/tween.cjs‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,7 @@ var Tween = /** @class */ (function () {
580580
temp.push(value);
581581
}
582582
if (isInterpolationList) {
583-
// if (_valuesStart[property] === undefined) { // handle end values only the first time. NOT NEEDED? setupProperties is now guarded by _propertiesAreSetUp.
584583
_valuesEnd[property] = temp;
585-
// }
586584
}
587585
}
588586
// handle the deepness of the values
@@ -1045,6 +1043,7 @@ var Timeline = /** @class */ (function () {
10451043
this._isPlaying = false;
10461044
this._isPaused = false;
10471045
this._pauseStart = 0;
1046+
this._lastUpdateTime = -Infinity;
10481047
this._onStartCallbackFired = false;
10491048
// Empty on purpose. Use `.add()` to compose.
10501049
// Sequential by default, parallel via explicit offsets, labels, or options.
@@ -1425,7 +1424,13 @@ var Timeline = /** @class */ (function () {
14251424
this._isPlaying = true;
14261425
this._isPaused = false;
14271426
this._onStartCallbackFired = false;
1428-
this._startTime = time;
1427+
// Snap to the ideal cycle boundary when restarting a finished
1428+
// timeline, so repeated timelines stay in sync with longer ones.
1429+
// Only when the caller's time is at or past the boundary;
1430+
// a time before the boundary means a deliberate scrub-back.
1431+
this._startTime = this._nextStartTime !== undefined && time >= this._nextStartTime ? this._nextStartTime : time;
1432+
this._nextStartTime = undefined;
1433+
this._lastUpdateTime = -Infinity;
14291434
for (var _i = 0, _a = this._entries; _i < _a.length; _i++) {
14301435
var entry = _a[_i];
14311436
if (entry.node.isPlaying())
@@ -1509,26 +1514,41 @@ var Timeline = /** @class */ (function () {
15091514
}
15101515
var timelineLocal = time - this._startTime;
15111516
var effectiveLocal = Math.min(timelineLocal, this._duration);
1517+
// Detect scrub-back vs forward play: only eagerly restart/clamp
1518+
// late-offset children when the playhead moved backward. On
1519+
// forward playback (including auto-restart), children start
1520+
// lazily so same-property tweens chain correctly.
1521+
var scrubbingBack = time < this._lastUpdateTime;
1522+
this._lastUpdateTime = time;
15121523
for (var _i = 0, _a = this._entries; _i < _a.length; _i++) {
15131524
var entry = _a[_i];
15141525
var child = entry.node;
15151526
if (!entry.started) {
1516-
// First start must wait until due; a never-started child left
1517-
// behind stays untouched. (Later re-starts are harmless and
1518-
// handled below, since Tween keeps its captured setup.)
1527+
// First start must wait until due; a never-started child
1528+
// left behind stays untouched.
15191529
if (effectiveLocal < entry.offset)
15201530
continue;
15211531
child.start(entry.offset);
15221532
entry.started = true;
15231533
}
15241534
else if (!child.isPlaying() && effectiveLocal < entry.offset + child.getTotalDuration()) {
1525-
// Re-enter when the playhead is inside the child's range after
1526-
// scrubbing back.
1527-
child.start(entry.offset);
1535+
// On scrub-back, eagerly re-enter children past the playhead
1536+
// so they snap to their start values. On forward playback
1537+
// only re-enter when the playhead has reached the child.
1538+
if (scrubbingBack || effectiveLocal >= entry.offset) {
1539+
child.start(entry.offset);
1540+
}
1541+
}
1542+
// On scrub-back, clamp late children to their offset so they
1543+
// output start values. On forward playback let the child
1544+
// start lazily (no output until playhead reaches offset).
1545+
if (scrubbingBack && effectiveLocal < entry.offset) {
1546+
child.update(entry.offset);
1547+
}
1548+
else if (!scrubbingBack && effectiveLocal < entry.offset && !child.isPlaying()) ;
1549+
else {
1550+
child.update(effectiveLocal);
15281551
}
1529-
// Clamp the lower end so reversed/scrubbed playheads snap the
1530-
// child to its start value instead of freezing on stale values.
1531-
child.update(effectiveLocal < entry.offset ? entry.offset : effectiveLocal);
15321552
}
15331553
if (!isFinite(this._duration)) {
15341554
if (this._onUpdateCallback)
@@ -1541,6 +1561,10 @@ var Timeline = /** @class */ (function () {
15411561
if (this._duration === 0 || time >= this._startTime + this._duration) {
15421562
if (this._onCompleteCallback)
15431563
this._onCompleteCallback(this);
1564+
// Store the ideal next-cycle boundary so start() snaps to it
1565+
// instead of the overshooting wall-clock time. This keeps
1566+
// repeated timelines precisely aligned with longer ones.
1567+
this._nextStartTime = this._startTime + this._duration;
15441568
this._isPlaying = false;
15451569
return false;
15461570
}

‎dist/tween.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ declare class Timeline {
303303
private _isPlaying;
304304
private _isPaused;
305305
private _pauseStart;
306+
private _nextStartTime;
307+
private _lastUpdateTime;
306308
private _onStartCallback?;
307309
private _onStartCallbackFired;
308310
private _onUpdateCallback?;

0 commit comments

Comments
 (0)