fix(parallax): prime parallax position with pre-Blazor init script to kill Server->WASM position pop

This commit is contained in:
daniel-c-harvey
2026-06-11 16:08:55 -04:00
parent e077b8ec7b
commit a2f9742f8a
3 changed files with 99 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@
<body> <body>
<Routes @rendermode="InteractiveAuto" /> <Routes @rendermode="InteractiveAuto" />
<script src="@Assets["_content/DeepDrftShared.Client/scripts/parallax-init.js"]"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script> <script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script type="module"> <script type="module">
@@ -141,6 +141,14 @@ export function register(element: HTMLElement, options: RegisterOptions): string
setupAutoHeight(handle); setupAutoHeight(handle);
// Prime position synchronously so enhanced navigation and WASM handoff have
// zero-frame gap. The init script covers cold page load; this covers nav.
// Skip under reduced motion — parallax.ts never drives position then.
if (!reducedMotion()) {
element.setAttribute('data-parallax-active', '');
applyParallax(handle);
}
return id; return id;
} }
@@ -0,0 +1,90 @@
/*
* parallax-init — synchronous pre-Blazor primer for ParallaxImage.
*
* Why this exists (the Server→WASM "position pop"):
* App.razor uses InteractiveAuto. On a cold WASM cache the page renders
* server-side, the Server circuit hydrates and parallax.js register()s
* (setting data-parallax-active, which cancels the CSS view() animation),
* then WASM takes over: Blazor disposes the Server component → DisposeAsync
* → unregister() → data-parallax-active is REMOVED. For the multi-frame gap
* until WASM's new instance re-register()s (its IntersectionObserver fires
* asynchronously), the CSS animation resumes and drives background-position-y
* to its own view()-timeline value, which differs from the JS math — that
* brief reversion is the visible pop.
*
* This script runs ONCE, synchronously, after the DOM is parsed but before
* Blazor boots, and sets data-parallax-active on every parallax window. That
* attribute is never tied to a component instance, so it survives the
* Server→WASM handoff — the CSS animation stays cancelled the whole time and
* parallax.js remains the sole writer of background-position-y across both
* render modes. register()/unregister() lifecycle is unchanged; on genuine
* navigation the element leaves the DOM entirely, so a stale attribute is moot.
*
* Why plain JS, not TypeScript:
* A synchronous pre-Blazor primer must be a classic <script> (no module
* graph, no async import). The RCL's TS pipeline emits ESNext modules, which
* are deferred — too late to beat the handoff. This file is therefore a
* hand-authored static asset, deliberately outside Interop/.
*
* Math parity with parallax.ts applyParallax():
* speed and direction are recovered from the inline custom properties the
* component already sets (--parallax-from / --parallax-to encode
* ParallaxSpeed and InvertDirection), then the SAME progress→position formula
* is applied. Any divergence here would itself be a pop, so the two must stay
* in lockstep — change one, change the other.
*/
(function () {
'use strict';
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function prime(win) {
var styles = getComputedStyle(win);
var from = parseFloat(styles.getPropertyValue('--parallax-from')) || 0;
var to = parseFloat(styles.getPropertyValue('--parallax-to')) || 0;
// Component encodes: non-inverted → from:0, to:end; inverted → from:end, to:0.
// Recover the original inputs from whichever endpoint carries the magnitude.
var invert = from > to;
var speed = clamp(Math.max(from, to) / 100, 0, 1);
var rect = win.getBoundingClientRect();
var viewportH = window.innerHeight;
var progress = invert ? rect.top / viewportH : 1 - rect.top / viewportH;
progress = clamp(progress, 0, 1);
var pos = progress * speed * 100;
var layers = win.querySelectorAll(':scope > .layer');
for (var i = 0; i < layers.length; i++) {
layers[i].style.backgroundPositionY = pos + '%';
}
// Cancel the CSS animation and adopt the JS value atomically.
win.setAttribute('data-parallax-active', '');
}
function primeAll() {
// Match parallax.ts: under reduced motion the module skips its scroll
// listener and never sets data-parallax-active, leaving the layer at
// --parallax-from (CSS animation is already `none` via media query).
// Do the same here so the two paths stay consistent.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
var windows = document.querySelectorAll('.parallax-window');
for (var i = 0; i < windows.length; i++) {
prime(windows[i]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', primeAll);
} else {
primeAll();
}
})();