Add minimal reproduction of compositor bug

This commit is contained in:
2026-07-22 01:51:22 +10:00
parent c27e5906ca
commit d28b09dc21
2 changed files with 334 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
# WebKit nested composite-transform frame pacing reproduction
This is a dependency-free reproduction for an iOS WebKit frame-pacing issue involving two nested animation clocks:
- A route-sized parent surface whose `translate3d()` progress is updated by `requestAnimationFrame`.
- A child with an infinite CSS `rotate()` animation, drawn as a rounded border spinner.
On the originating device, spinner durations within part of the `0.5s``1.1s` range produced regular 3334 ms rAF gaps, while durations outside that band were smooth. The workload per frame is effectively unchanged, making the duration-dependent behavior unexpected.
## Run
Open `index.html` directly. The reproduction is a single, self-contained HTML file and works from a `file://` URL without a server.
To test it through a local HTTP server instead, run this from the repository root:
```sh
npx vite external/bugs/composite-transform
```
This directory is independent of the Vue demo and has no build step or runtime dependencies. The CSS, test scene, controls, and measurement code are all in `index.html`, so that file can be attached directly to a browser bug report.
## Protocol
1. Do not scroll or touch the page while a sample is running.
2. Run **Sweep 0.51.1 s** with **rAF-driven** selected and **Spinner enabled** checked.
3. Repeat with **Static translate3d parent**.
4. Repeat with **No parent transform**.
5. Clear **Spinner enabled** and run the sweep as a control.
Each duration runs for 1.8 seconds. In the rAF-parent case, the incoming route moves for 700 ms and then remains stationary for the rest of the measurement.
## Expected
Changing only `animation-duration` should alter angular velocity, not cause a stable 60 Hz page to begin missing display deadlines. The rAF stream should remain close to 16.7 ms throughout the sweep on a 60 Hz device.
## Actual observation in the source application
The problematic sample showed:
- A 16 ms baseline.
- Repeated 3334 ms gaps during the route transition.
- The same gaps continuing while the parent was stationary and the spinner remained visible.
- Normal pacing after the spinner was removed from the DOM.
- No route-loading delay, JavaScript long task, or layout shift correlated with the gaps.
The reproduction intentionally contains no Vue, Vue Router, Suspense, application store, network request, or service-worker caching logic in its measured path.

View File

@@ -0,0 +1,288 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nested transform frame-pacing reproduction</title>
<style>
:root {
color-scheme: dark;
font: 15px/1.4 system-ui, sans-serif;
background: #0b0d12;
color: #f5f7ff;
}
* { box-sizing: border-box; }
body {
max-width: 760px;
margin: auto;
padding: 24px;
}
h1 { margin-bottom: 8px; font-size: 24px; }
p { color: #aeb6ca; }
.controls {
display: grid;
gap: 12px;
margin: 24px 0;
padding: 16px;
border: 1px solid #30384c;
border-radius: 12px;
background: #151923;
}
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
input, select, button {
min-height: 40px;
padding: 8px 12px;
border: 1px solid #3a4359;
border-radius: 8px;
background: #202637;
color: inherit;
font: inherit;
}
input { width: 110px; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .5; }
.buttons { display: flex; gap: 8px; }
.viewport {
position: relative;
height: 300px;
overflow: hidden;
border: 1px solid #30384c;
border-radius: 16px;
background: #11141c;
}
.under, .route {
position: absolute;
inset: 0;
padding: 28px;
}
.under {
background: linear-gradient(135deg, #171c28, #11141c);
color: #8f99b0;
}
.route {
transform: translate3d(100%, 0, 0);
background: #202637;
will-change: transform;
}
.card {
display: flex;
align-items: center;
gap: 14px;
margin-top: 24px;
padding: 18px;
border-radius: 12px;
background: #151923;
}
.spinner {
width: 34px;
height: 34px;
flex: none;
border: 3px solid #4b5570;
border-top-color: #74a5ff;
border-radius: 50%;
animation: spin var(--spinner-duration, .7s) linear infinite;
}
.spinner.off { animation: none; }
@keyframes spin {
to { transform: rotate(360deg); }
}
table {
width: 100%;
margin-top: 20px;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th, td {
padding: 8px;
border-bottom: 1px solid #30384c;
text-align: right;
}
th:first-child, td:first-child { text-align: left; }
.bad { color: #ff9e9e; }
</style>
</head>
<body>
<h1>Nested composite-transform frame pacing</h1>
<p>
A CSS border spinner rotates inside a route whose parent transform is driven by
<code>requestAnimationFrame</code>. On iOS WebKit, changing only the spinner duration can
cause repeatable 3334 ms frame gaps.
</p>
<div class="controls">
<label>
Spinner duration
<span><input id="duration" type="number" min="0.1" max="3" step="0.05" value="0.7"> s</span>
</label>
<label>
Parent transform
<select id="parent-mode">
<option value="moving">rAF-driven</option>
<option value="static">Static translate3d</option>
<option value="none">None</option>
</select>
</label>
<label>
Spinner enabled
<input id="spinner-enabled" type="checkbox" checked>
</label>
<div class="buttons">
<button id="run" type="button">Run once</button>
<button id="sweep" type="button">Sweep 0.51.1 s</button>
<button id="stop" type="button" disabled>Stop</button>
</div>
<output id="status">Ready. Each run measures 1.8 seconds.</output>
</div>
<div class="viewport">
<div class="under">Existing route</div>
<div id="route" class="route">
<strong>Incoming route</strong>
<div class="card">
<span id="spinner" class="spinner"></span>
<span>Waiting for asynchronous data…</span>
</div>
</div>
</div>
<table>
<thead>
<tr><th>Spinner</th><th>p95</th><th>Max</th><th>Frames &gt; 30 ms</th></tr>
</thead>
<tbody id="results"><tr><td colspan="4">No runs yet.</td></tr></tbody>
</table>
<script>
const durations = [.5, .6, .7, .8, .9, 1, 1.1]
const sampleTime = 1800
const route = document.querySelector('#route')
const spinner = document.querySelector('#spinner')
const duration = document.querySelector('#duration')
const parentMode = document.querySelector('#parent-mode')
const spinnerEnabled = document.querySelector('#spinner-enabled')
const runButton = document.querySelector('#run')
const sweepButton = document.querySelector('#sweep')
const stopButton = document.querySelector('#stop')
const status = document.querySelector('#status')
const results = document.querySelector('#results')
let token = 0
let rows = []
const percentile = (values, amount) => {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor((sorted.length - 1) * amount)] || 0
}
function configure(seconds) {
document.documentElement.style.setProperty('--spinner-duration', `${seconds}s`)
spinner.classList.toggle('off', !spinnerEnabled.checked)
spinner.style.animation = 'none'
void spinner.offsetWidth
spinner.style.animation = ''
route.style.willChange = parentMode.value === 'none' ? 'auto' : 'transform'
route.style.transform = parentMode.value === 'moving'
? 'translate3d(100%, 0, 0)'
: parentMode.value === 'static' ? 'translate3d(0, 0, 0)' : 'none'
}
function measure(seconds, currentToken) {
configure(seconds)
status.value = `Measuring ${seconds.toFixed(2)} s…`
return new Promise(resolve => {
let started
let previous
const deltas = []
function frame(now) {
if (currentToken !== token) return resolve(null)
started ??= now
previous ??= now
const elapsed = now - started
if (elapsed > 0) deltas.push(now - previous)
previous = now
if (parentMode.value === 'moving') {
const progress = Math.min(1, elapsed / 700)
const eased = 1 - Math.pow(1 - progress, 3)
route.style.transform = `translate3d(${(1 - eased) * 100}%, 0, 0)`
}
if (elapsed < sampleTime) requestAnimationFrame(frame)
else resolve({
seconds,
p95: percentile(deltas, .95),
max: Math.max(...deltas),
over30: deltas.filter(value => value > 30).length,
})
}
requestAnimationFrame(frame)
})
}
function render() {
results.innerHTML = rows.map(row => `
<tr class="${row.over30 ? 'bad' : ''}">
<td>${row.seconds.toFixed(2)} s</td>
<td>${row.p95.toFixed(1)} ms</td>
<td>${row.max.toFixed(1)} ms</td>
<td>${row.over30}</td>
</tr>`).join('') || '<tr><td colspan="4">No runs yet.</td></tr>'
}
async function run(values) {
const currentToken = ++token
rows = []
render()
runButton.disabled = sweepButton.disabled = true
stopButton.disabled = false
for (const value of values) {
const result = await measure(value, currentToken)
if (!result) break
rows.push(result)
render()
}
if (currentToken === token) status.value = 'Finished.'
runButton.disabled = sweepButton.disabled = false
stopButton.disabled = true
}
runButton.addEventListener('click', () => run([Number(duration.value)]))
sweepButton.addEventListener('click', () => run(durations))
stopButton.addEventListener('click', () => {
token++
status.value = 'Stopped.'
})
duration.addEventListener('input', () => configure(Number(duration.value)))
parentMode.addEventListener('change', () => configure(Number(duration.value)))
spinnerEnabled.addEventListener('change', () => configure(Number(duration.value)))
configure(Number(duration.value))
</script>
</body>
</html>