hero.js: canvaset og navnet venter til appen har hydrert
lint-and-reload / lint (push) Successful in 2s

Det var DOM-en lagt inn i .hero-piece før appen hydrerte som kastet
hydreringen av sporet (målt på nettstedet: uten den hydrerte siden
hver gang, med den aldri). Stilarket går inn med en gang, canvaset og
navnet når portal har adoptert instansen (__heroAdopted), eller etter
fire sekunder uten app, og toner inn. De midlertidige ?hero=-bryterne
er tatt ut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
blandClaude Fable 5.1 committed 2026-10-21 15:13:55 +02:00
1 parent 30ef37381b
commit 53841c8fde
1 file changed
+53 -11
+53 -11
View File
@@ -44,6 +44,9 @@ const TAU_MS = 140;
// browser scales the canvas up to the screen; at 1080p that is about
// 0.7 of the screen's pixels, at 4K about a third.
const MAX_PIXELS = 1.2e6;
// How long to wait for the app to adopt the piece before drawing it
// anyway (no wasm, a blocked script): longer than any hydration.
const ATTACH_FALLBACK_MS = 4000;
// /usr/share/gpupaper/shaders/light_line.frag, as is.
const LIGHT_LINE_FRAG = `// Lightning
@@ -232,6 +235,14 @@ const CSS = `
height: 100%;
display: block;
pointer-events: none;
animation: westra-in 700ms ease-out;
}
/* They arrive after the app has hydrated (see attachWhenHydrated), so
they arrive as a fade rather than a pop. */
@keyframes westra-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* The name, laid out as the logo lays it out: a box with the logo's
@@ -245,6 +256,7 @@ const CSS = `
.westra-name {
position: absolute;
inset: 0;
animation: westra-in 700ms ease-out;
display: flex;
align-items: center;
justify-content: center;
@@ -308,7 +320,6 @@ const CSS = `
`;
function ensureStyle() {
if (DEBUG.has('nostyle')) return;
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
@@ -337,7 +348,7 @@ class WestraHero {
this.hero = container.closest('.hero') || container.parentElement;
// Where the module's custom properties go: an element the app
// never hydrates, so writing to it cannot disturb hydration.
this.host = DEBUG.has('nobody') ? document.createElement('div') : document.body;
this.host = document.body;
this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
this.dark = window.matchMedia('(prefers-color-scheme: dark)');
@@ -356,9 +367,9 @@ class WestraHero {
mark.append(a, b);
this.name.append(mark);
this.mark = mark;
// Both inside the piece, and nothing outside it (see .westra-line).
// Both go inside the piece, and nothing outside it (see
// .westra-line) - but not yet: see attachWhenHydrated().
this.container = container;
if (!DEBUG.has('nodom')) container.append(this.canvas, this.name);
this.width = 0;
this.height = 1;
@@ -367,7 +378,44 @@ class WestraHero {
this.t0 = performance.now();
this.last = this.t0;
this.raf = 0;
this.attached = false;
this.attachWhenHydrated();
}
// Portal starts this module at HTML parse time, before its app
// has hydrated the page, and hydration walks the DOM as served:
// nodes added to the piece before it runs throw it off (verified
// on the live site: with the DOM added at once the router never
// came up and every click was a full page load; with nothing
// added it hydrated every time). So the stylesheet goes in at
// once (the sticky header and the hidden wordmark are CSS) and
// the canvas and the name wait for the app to adopt this
// instance (portal sets __heroAdopted when it does), or for
// ATTACH_FALLBACK_MS if no app comes - then they fade in. On a
// client-side navigation back here the app mounts the module
// itself, after hydration, so they go in at once.
attachWhenHydrated() {
const ready = () => window.__heroAdopted === true || performance.now() - this.t0 > ATTACH_FALLBACK_MS;
const poll = () => {
if (this.stopped) return;
if (!ready()) {
this.raf = requestAnimationFrame(poll);
return;
}
// One more frame after the flag, so the app's own work
// for this frame is done before the piece changes.
this.raf = requestAnimationFrame(() => {
this.raf = 0;
if (!this.stopped) this.attach();
});
};
poll();
}
attach() {
this.attached = true;
this.container.append(this.canvas, this.name);
this.gl = this.initGl();
this.size();
@@ -583,6 +631,7 @@ class WestraHero {
stop() {
this.stopped = true;
if (this.raf) cancelAnimationFrame(this.raf);
if (!this.attached) return;
window.removeEventListener('resize', this._onResize);
window.removeEventListener('scroll', this._onScroll);
if (this.observer) this.observer.disconnect();
@@ -602,13 +651,6 @@ class WestraHero {
}
}
// Temporary, to find what disturbs hydration on the live site:
// ?hero=off mounts nothing, ?hero=nostyle skips the stylesheet,
// ?hero=nobody skips the custom properties on <body>, ?hero=nodom
// adds nothing to the piece. Removed once known.
const DEBUG = new Set((new URLSearchParams(location.search).get('hero') || '').split(','));
export function mount(container) {
if (DEBUG.has('off')) return { stop() {} };
return new WestraHero(container);
}