diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 21:53:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 21:53:21 +0900 |
| commit | ae0a99aeec89580fa1121def99c954a1650fd4cb (patch) | |
| tree | a514a29e1e96b6fc66a5c46a446f56ed107f7da6 /src | |
| parent | 511d415b4ec07d435ea73ab8e5421949392d922d (diff) | |
| download | dispatch-web-ae0a99aeec89580fa1121def99c954a1650fd4cb.tar.gz dispatch-web-ae0a99aeec89580fa1121def99c954a1650fd4cb.zip | |
perf(tabs): hand-rolled FLIP — close animation starts instantly (was ~430ms of GSAP Flip work)
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/tabs/ui.test.ts | 52 | ||||
| -rw-r--r-- | src/features/tabs/ui/tab-flip.ts | 366 |
2 files changed, 303 insertions, 115 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index adfb3fb..c8e07ad 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -425,6 +425,11 @@ describe("TabList", () => { }); describe("FLIP animation (motion allowed)", () => { + /** Jump every running GSAP animation to `progress` (0–1), delays included. */ + function seekAnimations(progress: number): void { + for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress); + } + // jsdom has no matchMedia, which the grid reads as "no motion" — stub // the outermost edge so the GSAP Flip path actually runs. beforeEach(() => { @@ -448,8 +453,13 @@ describe("TabList", () => { it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => { const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId)); const { rerender } = render(TabList, { props: { ...props, tabs: two } }); + // jsdom lays nothing out (every box is 0×0) — give elements a real size. + const rect = vi + .spyOn(Element.prototype, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 0, 120, 72)); // Close c1: its card AND p's group box leave. await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") }); + rect.mockRestore(); const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")]; expect(ghosts).toHaveLength(2); @@ -465,11 +475,15 @@ describe("TabList", () => { // A clipping frame around a copy of the tint only — no card copies. expect(boxGhost.childElementCount).toBe(1); expect(boxGhost.firstElementChild?.childElementCount).toBe(0); - // Ghosts SHRINK (a clipped height), they don't fade or scale. - for (const g of ghosts) { + // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway + // through, each is shorter than it started; at the end they're gone. + const full = ghosts.map((g) => Number.parseFloat(g.style.height)); + seekAnimations(0.5); + ghosts.forEach((g, i) => { expect(g.style.overflow).toBe("hidden"); - expect(gsap.getTweensOf(g)).toHaveLength(1); - } + expect(g.style.opacity).toBe(""); + expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0); + }); const cardGhost = ghosts.find((g) => g !== boxGhost); expect(cardGhost?.parentElement).toBe(grid.parentElement); expect(cardGhost).toHaveTextContent("Child 1"); @@ -479,6 +493,24 @@ describe("TabList", () => { "solo", ]); expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2); + seekAnimations(1); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + }); + + it("measures each element at most twice per change (no layout thrash)", async () => { + // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per + // close with ~40 nested tabs. The hand-rolled FLIP reads every element's + // box once before the change and once after — never per tween. + const { rerender } = render(TabList, { props: { ...props, tabs: grouped } }); + const elements = document.querySelectorAll("[data-flip-id]").length; + const spy = vi.spyOn(Element.prototype, "getBoundingClientRect"); + try { + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + // 2 per element + 1 host read per ghost (one ghost here: c2's card). + expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1); + } finally { + spy.mockRestore(); + } }); it("does not ghost anything when a subagent is summoned", async () => { @@ -487,10 +519,14 @@ describe("TabList", () => { await rerender({ ...props, tabs: grouped }); expect(document.querySelector("[data-tab-ghost]")).toBeNull(); expect(screen.getAllByRole("tab")).toHaveLength(grouped.length); - // New cards start fully clipped (0 visible height) and grow in. - for (const id of ["c1", "c2", "g"]) { - expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)"); - expect(gsap.getTweensOf(card(id))).toHaveLength(1); + // New cards start fully clipped (0 visible height) and grow in from + // the top edge; once done no motion style is left behind. + const ids = ["c1", "c2", "g"]; + for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)"); + seekAnimations(1); + for (const id of ids) { + expect(card(id).style.clipPath).toBe(""); + expect(card(id).style.transform).toBe(""); } }); }); diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts index 2de00ab..ac9daaf 100644 --- a/src/features/tabs/ui/tab-flip.ts +++ b/src/features/tabs/ui/tab-flip.ts @@ -1,34 +1,43 @@ import { gsap } from "gsap"; -import { Flip } from "gsap/Flip"; import { isGroupFlipId, leavingFlipIds } from "../tab-flip"; -gsap.registerPlugin(Flip); - /** - * GSAP Flip animation for the tab grid — the DOM EFFECT half of the tab - * FLIP (the pure id scheme lives in `../tab-flip.ts`). + * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme + * lives in `../tab-flip.ts`). * * Driven by the owning component in two calls: - * - `capture(nextIds)` — BEFORE the DOM updates: record every `[data-flip-id]` - * element's position (First), and spawn an inert GHOST for each element the - * next render drops (a closed tab / dissolved group box). - * - `play()` — AFTER the DOM updates: run three STAGGERED phases, each only - * when it has something to do — + * - `capture(nextIds)` — BEFORE the DOM updates: measure every + * `[data-flip-id]` element (First), and build an inert GHOST for each + * element the next render drops (a closed tab / dissolved group box). + * - `play()` — AFTER the DOM updates: measure again (Last), then run three + * STAGGERED phases, each only when it has something to do — * 1. SHRINK — ghosts collapse to 0 height (everything else holds still); - * 2. MOVE — surviving elements Flip from their old box to their new - * one (Last → Invert → Play); + * 2. MOVE — cards slide from their old box to their new one, and each + * group box's tint morphs from its old rect to its new one; * 3. GROW — new cards (and new group boxes' tints) grow from 0 height. * + * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip + * recorded computed styles per element and re-measured while building its + * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which + * both delayed the click and swallowed the shrink phase. Here each change + * costs exactly two batched layout reads (one in `capture`, one in `play`); + * every write happens after its batch of reads. Each phase is ONE GSAP tween + * on a plain progress value whose `onUpdate` writes inline styles directly — + * GSAP never reads an element's computed style (per-element CSS tweens did, + * forcing a style recalc per moved card). Cards only TRANSLATE (plus a + * width tween when a card turns into / out of a double-width group parent); + * a group box never moves itself — only its absolutely-positioned, empty tint + * layer does (inset tween), so no card is ever double-translated. + * * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change - * only a HEIGHT, clipped — the contents keep their natural size and are - * revealed / hidden from the bottom edge, never scaled or faded. Grow is a - * `clip-path` reveal so the grid's layout (already settled by MOVE) never - * shifts under it. + * only a HEIGHT, clipped — contents keep their natural size. Grow is a + * `clip-path` reveal so the settled grid never shifts under it. * * Data-driven, not click-driven: it animates ANY change to the tab set (a * summoned subagent, a close from another device, a regroup). Re-entrant — - * `Flip.getState` captures mid-flight positions. Disabled when the user - * prefers reduced motion (or the environment can't tell, e.g. jsdom). + * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight + * motion before measuring the new layout. Disabled when the user prefers + * reduced motion (or the environment can't tell, e.g. jsdom). */ export interface TabFlip { capture(nextIds: ReadonlySet<string>): void; @@ -45,18 +54,24 @@ const MOVE_EASE = "power2.inOut"; /** Open: starts fast, settles slowly (the mirror of close). */ const GROW_EASE = "power2.out"; const GHOST_ATTR = "data-tab-ghost"; -/** Fully clipped from the bottom edge up / fully revealed. */ +/** Fully clipped from the bottom edge up (0 visible height). */ const CLIP_HIDDEN = "inset(0% 0% 100% 0%)"; -const CLIP_SHOWN = "inset(0% 0% 0% 0%)"; +/** Every inline style this module may leave on a live element mid-flight. */ +const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"]; interface Captured { - readonly state: ReturnType<typeof Flip.getState>; + /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */ readonly rects: ReadonlyMap<string, DOMRect>; - readonly ghosted: boolean; + /** Built but not yet started — their shrink starts in `play`. */ + readonly ghosts: readonly HTMLElement[]; } export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip { let captured: Captured | null = null; + /** Live elements carrying motion styles, so `play` can reset exactly those. */ + const animating = new Set<HTMLElement>(); + /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */ + let phases: gsap.core.Tween[] = []; return { capture(nextIds) { @@ -65,14 +80,20 @@ export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFl if (scroller === undefined || !motionAllowed()) return; const els = flipElements(scroller); if (els.length === 0) return; - const rects = new Map(els.map((el) => [el.dataset.flipId ?? "", el.getBoundingClientRect()])); - const byId = new Map(els.map((el) => [el.dataset.flipId ?? "", el])); - let ghosted = false; - for (const id of leavingFlipIds([...byId.keys()], nextIds)) { - const el = byId.get(id); - if (el !== undefined) ghosted = spawnGhost(scroller, el, isGroupFlipId(id)) || ghosted; - } - captured = { state: Flip.getState(els), rects, ghosted }; + + // READS (one layout). + const rects = new Map<string, DOMRect>(); + for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect()); + const leaving = leavingFlipIds([...rects.keys()], nextIds); + const byId = new Map(els.map((el) => [idOf(el), el])); + const plans = leaving + .map((id) => byId.get(id)) + .filter((el): el is HTMLElement => el !== undefined) + .map((el) => planGhost(scroller, el, rects.get(idOf(el)))) + .filter((p): p is GhostPlan => p !== null); + + // WRITES. + captured = { rects, ghosts: plans.map(insertGhost) }; }, play() { @@ -81,61 +102,155 @@ export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFl const scroller = getScroller(); if (prev === null || scroller === undefined) return; - const els = flipElements(scroller); - const staying = els.filter((el) => prev.rects.has(el.dataset.flipId ?? "")); - const entering = els.filter((el) => !prev.rects.has(el.dataset.flipId ?? "")); - const moved = staying.some((el) => { - const before = prev.rects.get(el.dataset.flipId ?? ""); - return before !== undefined && !sameBox(before, el.getBoundingClientRect()); + // Reset in-flight motion on live elements so the read below is the + // settled layout (their previous VISUAL boxes are already in `prev`). + for (const tween of phases) tween.kill(); + phases = []; + for (const el of animating) clearMotion(el); + animating.clear(); + + // READS (one layout). + const next = flipElements(scroller).map((el) => { + const target = measureTarget(el); + return { target, id: idOf(el), rect: target.getBoundingClientRect() }; }); - // Phase timing: skip a phase that has nothing to animate. - const moveAt = prev.ghosted ? SHRINK : 0; - const growAt = moveAt + (moved ? MOVE : 0); - - // 3. GROW — hide new elements NOW (through shrink + move), reveal later. - for (const el of entering) { - const target = isGroupFlipId(el.dataset.flipId ?? "") - ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") // tint only - : el; - if (target === null) continue; - gsap.killTweensOf(target, "clipPath"); - gsap.fromTo( - target, - { clipPath: CLIP_HIDDEN }, + // Plan (pure arithmetic — no DOM access). + const entering: HTMLElement[] = []; + const movers: Mover[] = []; + for (const { target, id, rect } of next) { + const before = prev.rects.get(id); + if (before === undefined) entering.push(target); + else if (!sameBox(before, rect)) + movers.push(planMove(target, isGroupFlipId(id), before, rect)); + } + const moveAt = prev.ghosts.length > 0 ? SHRINK : 0; + const growAt = moveAt + (movers.length > 0 ? MOVE : 0); + + // WRITES — first frame of every phase NOW (movers hold at their old + // boxes, new elements are fully clipped) so nothing flashes in between. + for (const m of movers) m.render(0); + for (const el of entering) el.style.clipPath = CLIP_HIDDEN; + + // 1. SHRINK — one tween drives every ghost. + if (prev.ghosts.length > 0) { + const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) })); + gsap.to( + { p: 0 }, { - clipPath: CLIP_SHOWN, - duration: GROW, - delay: growAt, - ease: GROW_EASE, - clearProps: "clipPath", + p: 1, + duration: SHRINK, + ease: SHRINK_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`; + }, + onComplete: () => { + for (const { g } of ghosts) g.remove(); + }, }, ); } - // 2. MOVE — Flip holds survivors at their old boxes until `moveAt`. - if (moved) { - Flip.from(prev.state, { - targets: staying, - duration: MOVE, - delay: moveAt, - ease: MOVE_EASE, - nested: true, - }); + // 2. MOVE — one tween drives every card slide + tint morph. + if (movers.length > 0) { + for (const m of movers) animating.add(m.el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: MOVE, + delay: moveAt, + ease: MOVE_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const m of movers) m.render(p); + }, + onComplete: () => { + for (const m of movers) { + clearMotion(m.el); + animating.delete(m.el); + } + }, + }, + ), + ); + } + + // 3. GROW — one tween reveals every new card / tint from its top edge. + if (entering.length > 0) { + for (const el of entering) animating.add(el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: GROW, + delay: growAt, + ease: GROW_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`; + for (const el of entering) el.style.clipPath = clip; + }, + onComplete: () => { + for (const el of entering) { + clearMotion(el); + animating.delete(el); + } + }, + }, + ), + ); } }, }; } -/** Whether two rects are the same box (sub-pixel tolerance). */ -function sameBox(a: DOMRect, b: DOMRect): boolean { - const close = (x: number, y: number) => Math.abs(x - y) < 0.5; - return ( - close(a.left, b.left) && - close(a.top, b.top) && - close(a.width, b.width) && - close(a.height, b.height) - ); +interface Mover { + readonly el: HTMLElement; + /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */ + render(p: number): void; +} + +/** + * A card slides (and, when it turned into / out of a double-width group + * parent, resizes) from its old box; a group tint morphs its insets from its + * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets). + */ +function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover { + if (isTint) { + const top = from.top - to.top; + const left = from.left - to.left; + const right = to.right - from.right; + const bottom = to.bottom - from.bottom; + return { + el, + render(p) { + const k = 1 - p; + el.style.top = `${top * k}px`; + el.style.left = `${left * k}px`; + el.style.right = `${right * k}px`; + el.style.bottom = `${bottom * k}px`; + }, + }; + } + const dx = from.left - to.left; + const dy = from.top - to.top; + const resized = Math.abs(from.width - to.width) >= 0.5; + return { + el, + render(p) { + const k = 1 - p; + el.style.transform = `translate(${dx * k}px, ${dy * k}px)`; + if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`; + }, + }; +} + +function clearMotion(el: HTMLElement): void { + for (const prop of MOTION_PROPS) el.style.removeProperty(prop); } function motionAllowed(): boolean { @@ -149,29 +264,72 @@ function flipElements(scroller: HTMLElement): HTMLElement[] { return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")]; } +function idOf(el: HTMLElement): string { + return el.dataset.flipId ?? ""; +} + +/** What VISUALLY represents a flip element: a card itself, a group box's tint. */ +function measureTarget(el: HTMLElement): HTMLElement { + if (!isGroupFlipId(idOf(el))) return el; + return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el; +} + +/** Whether two rects are the same box (sub-pixel tolerance). */ +function sameBox(a: DOMRect, b: DOMRect): boolean { + const close = (x: number, y: number) => Math.abs(x - y) < 0.5; + return ( + close(a.left, b.left) && + close(a.top, b.top) && + close(a.width, b.width) && + close(a.height, b.height) + ); +} + +interface GhostPlan { + readonly source: HTMLElement; + readonly host: HTMLElement; + readonly isGroup: boolean; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + /** - * An inert visual copy of a leaving element, absolutely positioned over its - * last on-screen rect, that SHRINKS to 0 height (phase 1) and removes itself. - * The ghost is a clipping frame around a full-size copy, so only the frame's - * height animates — the copy inside never resizes or reflows. + * READ half of a ghost: where an inert copy of a leaving element must sit. * * Paint order matters (cards are `relative`, so everything here stacks in DOM - * order): a leaving CARD's ghost is appended to the scroller so it shrinks - * ON TOP of the grid. A leaving GROUP BOX copies only its backdrop (the - * tint — its cards either flip elsewhere or ghost themselves) and is inserted - * as the FIRST child of the box's enclosing grid: above the enclosing box's - * own tint, but BELOW every card, so the dissolving tint never covers them. - * - * Returns whether a ghost was spawned. + * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A + * leaving GROUP BOX copies only its tint and goes FIRST in the box's + * enclosing grid: above the enclosing box's own tint, but BELOW every card. */ -function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): boolean { - const source = isGroup ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") : el; - if (source === null) return false; +function planGhost( + scroller: HTMLElement, + el: HTMLElement, + rect: DOMRect | undefined, +): GhostPlan | null { + const isGroup = isGroupFlipId(idOf(el)); + const source = measureTarget(el); const host = isGroup ? el.parentElement : scroller; - if (host === null) return false; - const r = el.getBoundingClientRect(); + if (host === null || rect === undefined || (isGroup && source === el)) return null; const h = host.getBoundingClientRect(); - const copy = source.cloneNode(!isGroup) as HTMLElement; + return { + source, + host, + isGroup, + left: rect.left - h.left - host.clientLeft + host.scrollLeft, + top: rect.top - h.top - host.clientTop + host.scrollTop, + width: rect.width, + height: rect.height, + }; +} + +/** + * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0) + * around a full-size copy — only the frame animates, the copy never resizes. + */ +function insertGhost(plan: GhostPlan): HTMLElement { + const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement; // A ghost is never a tab/group to queries, tests or assistive tech. for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) { for (const attr of [ @@ -184,13 +342,13 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): b node.removeAttribute(attr); } } - // The copy keeps the element's full size for the whole animation. Object.assign(copy.style, { position: "absolute", + inset: "auto", left: "0", top: "0", - width: `${r.width}px`, - height: `${r.height}px`, + width: `${plan.width}px`, + height: `${plan.height}px`, margin: "0", transform: "none", clipPath: "none", @@ -201,22 +359,16 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): b ghost.inert = true; Object.assign(ghost.style, { position: "absolute", - left: `${r.left - h.left - host.clientLeft + host.scrollLeft}px`, - top: `${r.top - h.top - host.clientTop + host.scrollTop}px`, - width: `${r.width}px`, - height: `${r.height}px`, + left: `${plan.left}px`, + top: `${plan.top}px`, + width: `${plan.width}px`, + height: `${plan.height}px`, overflow: "hidden", pointerEvents: "none", - zIndex: isGroup ? "auto" : "2", + zIndex: plan.isGroup ? "auto" : "2", }); ghost.appendChild(copy); - if (isGroup) host.prepend(ghost); - else host.appendChild(ghost); - gsap.to(ghost, { - height: 0, - duration: SHRINK, - ease: SHRINK_EASE, - onComplete: () => ghost.remove(), - }); - return true; + if (plan.isGroup) plan.host.prepend(ghost); + else plan.host.appendChild(ghost); + return ghost; } |
