Repository navigation
perf(core): Safari stops re-serializing inline styles on every GSAP write - #5974
miguel-heygen wants to merge 9 commits into
Conversation
…rite in a playing film
somanshreddy
left a comment
There was a problem hiding this comment.
Review at ba20af48. The WebKit fix is right and well measured: dropping [style*=…] from every injected rule ends the per-write inline-style re-serialization. I'm not approving yet, because of one cross-version case that needs an answer.
Older pinned @hyperframes/shader-transitions against the new runtime rule. Compositions load shader-transitions from the CDN at a pinned version, for example packages/producer/tests/hdr-regression/src/index.html loads @hyperframes/shader-transitions@0.4.11/dist/index.global.js. The runtime, though, is always the current one. Here's how the pieces interact:
- Released versions'
showSceneAndClipssetvisibility: visible !importantinline on the scene and on each[data-start]inside it, but don't callliftClipHiddenMark. They can't, since it's new. - The visibility pass still marks every timed clip it hides (
init.ts:3029,toggleAttribute(CLIP_HIDDEN_ATTR, …)), including an incoming scene's inner clips that start later. - Old rule:
[data-hf-clip-hidden][style*="visibility: hidden"] *stopped matching once the old capture rewrote the inline style tovisible, so those clips' children were captured. - New rule:
[data-hf-clip-hidden] * { visibility: hidden !important }still matches. The[data-start]elements themselves stay visible, because inline!importantbeats an author-sheet!important, but their plain descendants (text, divs, images withoutdata-start) are now hidden in the captured frame.
So a composition pinned to a released shader-transitions, whose incoming scene holds an inner clip that starts after the scene, would lose that clip's content from the transition's captured texture. I derived this from the cascade and haven't rendered it. The hdr-regression fixture (0.4.11) passing doesn't settle it unless it has an inner clip starting after its scene.
Ways to close it, your call:
- Render a fixture pinned to the current published shader-transitions with an inner timed clip that starts inside the incoming scene, and compare it against main. If it's unchanged, I'm wrong about the reachability, and a note in the body is enough.
- If it does change: keep a compat path that doesn't reintroduce a
[style]selector in the injected sheet. For example, the runtime could lift the mark on any element whose inline visibility is forced visible when it next runs its pass, or the release notes could require a minimum shader-transitions version with a lint warning on older pins.
What I checked, and it's fine:
- The
liftClipHiddenMarkhelper returns its restore function.showHiddenClipsrestores both the inline visibility and the marks. Studio's layer reveal puts the mark back only when it restores its own visibility, and the gesture preview puts it back on release. noExternal: ["@hyperframes/core"]bundles the helper into shader-transitions, so there's no new install dependency (it's a devDependency).- The remaining
[style*="visibility: hidden"]uses areclosest()/querySelectorAlllookups (init.ts:3090,layout-audit.browser.js:716), not injected rules, so they don't cause the WebKit cost. - The "author sets
visibility: visibleinline" behaviour change is stated in the body. For authors, that's fine.
— Somu
… added comment lines
Edit accuracy: accurate 271 (base branch 271), smooth 124 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (4)
|
vanceingalls
left a comment
There was a problem hiding this comment.
Review at 11c461b2. The WebKit diagnosis and the fix are right, and the measurements are careful. Dropping [style*=…] from every injected rule is the correct structural fix, and lifting the mark explicitly in showHiddenClips, capture.ts, the layer reveal and the gesture preview (each with a test that asserts both the lift and the put-back) is well done. One revealer was missed, and it sits on the default render path.
Blocker: the page-side shader compositor reveals scene copies without lifting the mark
packages/shader-transitions/src/engineModePageComposite.ts:287-302 (prepareComposite) does live.cloneNode(true), sets clone.style.visibility = "visible" and sets visibility = "visible" on every [data-start] inside the clone. It never calls liftClipHiddenMark. Its own comment says "Cf. forceSceneVisibleInClone", so it is a sibling of the capture.ts showSceneAndClips this PR fixes.
cloneNode(true)copiesdata-hf-clip-hidden. The copies go intolayoutsubtreestaging canvases appended todocument.body(:236-247), so the runtime's contents rule, whichhideTimedClipsUntilFirstPassinstalls in render as well as preview, applies to them.- Before:
[data-hf-clip-hidden][style*="visibility: hidden"] *stopped matching once the copy's inline visibility was set tovisible. - Now:
[data-hf-clip-hidden] * { visibility: hidden !important }keeps matching. Every plain descendant of a marked copy (or of a marked[data-start]inside it) is hidden, anddrawElementImagerasterizes the scene's box and background with its contents gone.
It is reachable by default:
enablePageSideCompositing: trueis the default (packages/engine/src/config.ts:307).- Shipped guidance places transitions across scene boundaries (
docs/guides/claude-design-hyperframes.md:264: "scene-3 ends at 8s, transition duration 0.5s -> time: 7.75"), withclass="scene clip"scenes that carrydata-start. So the visibility pass marks the incoming scene for the first half of the transition and the outgoing scene for the second half. The compositor's own test models this timing (engineModePageComposite.test.ts:150-163).
Result: in SDR MP4/GIF renders, part of every boundary-straddling shader transition blends from or to an empty scene background, and the content pops in or out at the clip boundary.
Why CI and the fixture sweep miss it: page-side-shader-compositor-render-compat has both scenes at data-start="0" data-duration="2" (src/index.html:84,89), so neither is ever marked. It also loads a vendored src/vendor/hyper-shader.global.js, not this package's source.
Fix: lift the mark on the copy and on its [data-start] descendants in prepareComposite. No put-back is needed, because the copies are thrown away. Add a compositor test with a marked scene so that removing the lift fails it.
This is separate from @somanshreddy's open question about older pinned shader-transitions builds. That one is version skew; this is the in-repo code at this head.
Nits (non-blocking)
timedClipHide.ts:22: the hide rule's specificity drops from (0,2,0) to (0,1,0), so an author!importantvisibility: visiblerule at (0,1,1) or above now beats it. I found no shipped template that does this.[data-hf-clip-hidden][data-hf-clip-hidden] *keeps the old weight without a[style]selector.packages/cli/src/commands/layout-audit.browser.js:716still mirrors the old[style*="visibility: hidden"]rule, and its comment says it follows core's timedClipHide rule. Nothing breaks, but the mirror has drifted.timedClipHide.test.ts: the "hides … by the mark alone" fixture also carries inlinevisibility: hidden, so it passes onmaintoo. The selector test is the one that tells old from new.
CI: every required check has a success run at 11c461b2.
Verdict: REQUEST CHANGES
Reasoning: The default page-side compositor path copies the mark into its scene copies without lifting it, so boundary-straddling shader transitions render empty scenes, and no fixture covers that case.
— Via
…eep the scene's contents Every revealer now goes through one core helper that shows the clip and lifts the runtime's hidden mark together; the page-side compositor's scene copies were still carrying the mark. The contents rule keeps its old weight.
…sition scenes Published shader builds reveal scenes by inline style without lifting the hidden mark. When one is on the page, the runtime keeps the inline-style rules; builds that lift the mark declare it on load and keep the fast rules.
|
Updated to @vanceingalls, blocker: fixed at the root, not only in Nits:
@somanshreddy, older pinned builds: you were right, and it reaches the default render path. I rendered your case: the page-side compositor fixture with the incoming scene starting mid-transition, the older vendored build, MP4. Without a compatibility path the incoming scene renders without its contents during the transition's first half. The frames are in the body. Fixed by a compatibility path. Current builds call |
…ip-attr # Conflicts: # packages/core/src/runtime/timedClipHide.ts
|
Updated to Conflict,
New real-Chrome tests in The earlier findings (the page-side compositor copy, older pinned shader builds, the rule's weight, the layout-audit mirror and the mark-only fixture) were fixed at |
There was a problem hiding this comment.
Verdict: APPROVE · 0 blocker · 0 important · 0 nits · head 4ad55282
All prior findings are resolved at this head. @vanceingalls's blocker is fixed: prepareComposite reveals the clone and its [data-start] children through revealClip (engineModePageComposite.ts:299-302). His three nits are fixed too: the doubled-attribute weight, the layout-audit mirror, and the mark-only fixture. @somanshreddy's question about older pinned builds is closed by keepInlineHideRulesForOlderShaders, which runs at bootstrap after composition scripts. The main merge kept both of main's timedClipHide changes, keyed on the mark. Every GitHub Actions workflow is green at this head.
Scope
I read all non-test source end to end: clipReveal.ts, timedClipHide.ts, entry.ts, the three shader-transitions sites, the two Studio hooks, and the layout-audit mirror. For each, I checked that the reveal and the put-back always happen together:
showHiddenClipsrestores the inline visibility and then the mark.- The layer reveal puts the mark back only when it restores its own visibility.
- The gesture's
??=keeps the mark state from the start of the gesture. - The capture and compositor copies are thrown away, so nothing needs putting back.
I grepped the rest of packages/ for any other code that sets inline visibility to visible:
- The generator's
tl.setbookends match the clip window. - The engine's
__render_frame__images aren't timed clips, so they never carry the mark. threeDProjectionrenders in its own SVG.
SKIPPED_CLIP and the closest() lookups in init.ts are JS queries, not injected rules. Sub-compositions are bundled into the page by the producer and the Studio server, so their shader scripts run before bootstrap. I didn't re-run the render fixtures and relied on the body's hash results.
What this fixes
Safari spent about a fifth of a playing film's main thread re-serializing inline styles. The runtime always injects a stylesheet that hides a hidden clip's contents with
[data-hf-clip-hidden][style*="visibility: hidden"] *(plus SVG filter variants). A[style]attribute selector above the matched element makes WebKit treat the style attribute as needed for invalidation, so every inline style write re-serializes the element's whole style to text (invalidateStyleAttribute→asTextAtom→asTextInternal). GSAP writestransformandopacityon every animated element every frame, so a GSAP-heavy film pays that on every write. Chrome serializes lazily and doesn't pay it.The rule now matches the runtime's own mark only:
[data-hf-clip-hidden][data-hf-clip-hidden] *hides a hidden clip's contents. The attribute is doubled to keep the old rule's weight, so author rules that lost to it still lose. The visibility pass sets the mark and the inlinevisibility: hiddentogether (init.ts), as before.revealClipin@hyperframes/core/runtime/clip-reveal. It sets the inline visibility and lifts the mark together and returns the call that puts the mark back, so a revealer cannot do one without the other:showHiddenClips);showSceneAndClips);prepareComposite), which are thrown away, so nothing is put back;noExternal, as the player does), so it gains no install dependency. Its build grows by the helper alone: 255,276 → 255,415 bytes ESM, 255,962 → 256,101 CJS, 255,775 → 255,914 IIFE (+139 each; +87, +71, +76 gzipped). The mark name appears once and no other core module is pulled in.Compatibility path for published shader-transitions builds
Builds of
@hyperframes/shader-transitionsalready on npm and the CDN reveal scenes by inline style only. They can't lift the mark. Under the mark-only rule, a film loading one of them (a pinned version, or the unpinned CDN URL before it serves this release) would render an empty incoming or outgoing scene for part of every shader transition across a scene boundary.Decision (Miguel): keep published builds rendering, chosen over shipping with a minimum shader-transitions version and a lint warning on older pins, and over holding the change.
declareClipRevealAware()on load.HyperShaderglobal, or__hf.transitions) that did not declare. If it finds one, it rewrites its two injected rules back to the inline-style form (keepInlineHideRulesForOlderShaders). Those films render as onmainand keep Safari's old cost. Every other film gets the fast rules.__hfSuppressSceneMutations: no new authored attribute and no author-facing API.keepInlineHideRulesForOlderShaders, its call inentry.ts,olderShaderBuildOnPageand the inline-style selector.Behaviour change
An author's own script that sets
visibility: visibleinline on a timed clip element while the runtime hides it no longer reveals that clip's children; they stay hidden until the runtime shows the clip. The runtime owns a timed clip's visibility, and the old behaviour cost every GSAP film this tax in Safari. Revealers inside HyperFrames (Studio, shader transitions) go throughrevealClip.Measured
WebKit 26.5 on a physical Mac (M4 Pro), the player playing at 960x540, page main thread from an Instruments Time Profiler trace, 6 s per run:
gsap-heavyfilm, beforeOn AC power, two runs per arm, the old and new builds served side by side and alternated in one session. Two earlier sessions on battery in Low Power Mode (WebKit capped at 30 fps for both builds) gave the same CPU picture: 318 to 329 ms/s before, 202 to 206 after. The long film's remaining limit is painting in WebKit's GPU process, which this PR does not touch.
Chrome, synthetic 4K film, after merging main
Chrome 152 headless on a 32-core Linux host with no GPU, so every frame is painted in software. The film is 3840x2160: 92 timed video tiles (about 31 playing at once), a scene of about 7,000 divs and 500 SVG paths that GSAP writes inline styles to every frame, and 40 later scenes, about 100 hidden clips in all. The player plays it as in Studio's preview for 10 s per run. The two builds alternate (
mainat this branch's merge base, then this head), three runs each, then one traced run each. The host was shared, with load average 10 to 25.mainThe CPU result is clear: an inline style write no longer restyles the hidden-clip rules' descendants, so most recalculations touch one element. Frame rate barely moves on this host, because software painting of 31 playing 4K-scaled videos dominates each frame. The noise between runs is larger than the gap.
Chrome, same 4K film, on a GPU host (Apple silicon)
The same film, builds and method on an Apple silicon Mac on AC power with Low Power Mode off: Chrome 152 headless, painting on the GPU (Metal through ANGLE, with GPU compositing, rasterization and video decode enabled). Three alternating runs each, then two traced runs each. The host was shared, with load average 8 to 16.
mainWith a GPU, both builds play close to the 60 fps headless Chrome draws at, and the gap between their medians (1.3 fps) is smaller than the spread between runs of one build (about 3 fps). This host shows no frame-rate gain beyond noise. The CPU result repeats: style recalculation falls from under half of the main thread to a fifth, and the main thread is busy about 40% less for the same 10 s of playback, which shows up as headroom rather than frames here.
Tests
timedClipHide.test.ts:!importantties by order.showHiddenClipsandrevealCliplift the mark and put it back.__hf.transitions), a scene it revealed by inline style shows its text and images, and a hidden one stays hidden. With a build that declared, the fast rules stay and none of them selects on inline style.revealClip, the compatibility path returning early, a single-attribute contents rule, and the package not declaring. Each break turned exactly its own test red.coreRuntimeBrowser.test.ts), in a render and in a preview:!importanton the contents rule.showHiddenClipskeeps the mark.Render output
Seven existing producer fixtures, rendered to PNG at
mainand with this change (Chrome, one worker), every frame hashed:timed-descendant-visibility(270 frames),many-cuts(168),sub-comp-t0(180),page-side-shader-compositor-render-compat(60).style-1-prodandoverlay-montage-proddiffer between two renders ofmainitself (96 frames for the montage).gsap-letters-render-compat, 35 of 120 frames, the same on every render. The cause is the removed selector itself, not a change in what is hidden: adding back a rule that never matches,[data-hf-never][style*="never"] * {}, restoresmain's bytes exactly. A[style]selector anywhere in the page changes how Chrome paints elements whose inline transform changes each frame; without it, the edge antialiasing of the rotating letters moves by under a pixel. Frame 77: 730 edge pixels of the "H" change, PSNR 43.9 dB (the fixture's floor is 30). The edges are no softer: 518 partially covered edge pixels against 608 onmain, with the same total edge contrast.CI note
The Windows
engine-clitest lane timed out in three CLI network tests this change does not touch: thepublicNetworkcontrolled-socket integration hook (30,000 ms limit), and incloud/download"splits a large file into parallel ranged requests" (20,494 ms) and "streams the whole file in one request when the server ignores Range" (20,004 ms), both against a 20,000 ms limit. The same workflow passed onmainat this branch's base. They are handed over as Windows flakes; the failed jobs are rerun at this head.Before
gsap-letters-render-compatframe 77 atmain, the "H" cropped and scaled 4x over black:After
The same crop with this change: the stroke edges land a fraction of a pixel differently, no softer.
Render across a scene boundary (page-side compositor, MP4)
The
page-side-shader-compositor-render-compatfixture withscene-bmoved to start at 1.2 s, inside the 0.75 s to 1.6 s glitch transition, so the runtime marks the incoming scene for the transition's first half. Rendered to MP4 with page-side compositing on (the default), video frames compared by decoded-frame MD5:mainvs this headmainvs this head without the compatibility pathFrames 26, 30 and 34 with the older shader build. Left to right:
main, this head without the compatibility path (no "BETA"), this head: