Add minimal reproduction of compositor bug
This commit is contained in:
46
external/bugs/composite-transform/README.md
vendored
Normal file
46
external/bugs/composite-transform/README.md
vendored
Normal 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 33–34 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.5–1.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 33–34 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.
|
||||
288
external/bugs/composite-transform/index.html
vendored
Normal file
288
external/bugs/composite-transform/index.html
vendored
Normal 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 33–34 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.5–1.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 > 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>
|
||||
Reference in New Issue
Block a user