Files

289 lines
8.0 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>