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