Skip to content

Commit c18f8b0

Browse files
authored
Merge pull request #420 from Weaverse/dev
v2026.6.11 - Hydrogen 2026.4.3 + dependency refresh, hero video fix
2 parents 1bf94cf + 7c34cd7 commit c18f8b0

11 files changed

Lines changed: 1605 additions & 1375 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Pilot supports AI coding agents (Cursor, Claude Code, Windsurf, Codex, GitHub Co
6161

6262
**Requirements:**
6363

64-
- Node.js version 20.0.0 or higher
64+
- Node.js version 22.12.0 or higher
6565
- `npm` package manager
6666

6767
**Follow these steps to get started with Pilot and begin crafting your Hydrogen-driven storefront:**

app/components/scroll-reveal.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export function ScrollReveal({
116116
}: ScrollRevealProps) {
117117
let { revealElementsOnScroll } = useThemeSettings<ThemeSettings>();
118118
let [isVisible, setIsVisible] = useState(false);
119+
let [revealed, setRevealed] = useState(false);
119120
let internalRef = useRef<HTMLElement>(null);
120121

121122
let setRefs = (node: HTMLElement | null) => {
@@ -141,7 +142,26 @@ export function ScrollReveal({
141142
return cleanup;
142143
}, [revealElementsOnScroll]);
143144

144-
if (!revealElementsOnScroll) {
145+
// Strip the reveal transition wrapper from the DOM once the animation
146+
// finishes so subsequent re-renders don't keep paying for the inline
147+
// transition/transform styles — and downstream hover/focus animations
148+
// on the same element aren't fighting an active `transition-all`.
149+
useEffect(() => {
150+
if (!isVisible || !internalRef.current) {
151+
return;
152+
}
153+
let el = internalRef.current;
154+
function onEnd(e: TransitionEvent) {
155+
// Only react to our own transitions (opacity always animates here).
156+
if (e.target === el && e.propertyName === "opacity") {
157+
setRevealed(true);
158+
}
159+
}
160+
el.addEventListener("transitionend", onEnd);
161+
return () => el.removeEventListener("transitionend", onEnd);
162+
}, [isVisible]);
163+
164+
if (!revealElementsOnScroll || revealed) {
145165
return (
146166
<Component ref={setRefs} className={className} style={style} {...rest}>
147167
{children}

app/routes/catch-all.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { LoaderFunctionArgs } from "react-router";
1+
import { getWeaverseSeoMeta } from "@weaverse/hydrogen";
2+
import type { LoaderFunctionArgs, MetaFunction } from "react-router";
23
import { routeHeaders } from "~/utils/cache";
34
import { validateWeaverseData, WeaverseContent } from "~/weaverse";
45

@@ -15,6 +16,10 @@ export async function loader({ context }: LoaderFunctionArgs) {
1516
};
1617
}
1718

19+
export const meta: MetaFunction<typeof loader> = ({ data }) => {
20+
return getWeaverseSeoMeta(data?.weaverseData);
21+
};
22+
1823
export default function Component() {
1924
return <WeaverseContent />;
2025
}

app/routes/home.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SeoConfig } from "@shopify/hydrogen";
22
import { AnalyticsPageType, getSeoMeta } from "@shopify/hydrogen";
3-
import type { PageType } from "@weaverse/hydrogen";
3+
import { getWeaverseSeoMeta, type PageType } from "@weaverse/hydrogen";
44
import type { LoaderFunctionArgs, MetaFunction } from "react-router";
55
import type { ShopQuery } from "storefront-api.generated";
66
import { seoPayload } from "~/.server/seo";
@@ -20,8 +20,10 @@ export async function loader(args: LoaderFunctionArgs) {
2020
type = "CUSTOM";
2121
}
2222

23-
// Calculate seo payload synchronously
24-
const seo = seoPayload.home();
23+
// INDEX uses the code-defined homepage SEO; CUSTOM pages (root-level
24+
// Weaverse handles served by this route) get their SEO from Weaverse
25+
// via getWeaverseSeoMeta in the meta export below.
26+
const seo = type === "INDEX" ? seoPayload.home() : null;
2527

2628
// Load async data in parallel for better performance
2729
const [weaverseData, { shop }] = await Promise.all([
@@ -46,7 +48,12 @@ export async function loader(args: LoaderFunctionArgs) {
4648
}
4749

4850
export const meta: MetaFunction<typeof loader> = ({ data }) => {
49-
return getSeoMeta(data?.seo as SeoConfig);
51+
// INDEX (real homepage) keeps the code-defined SEO — no Weaverse override.
52+
// CUSTOM pages served by this route get their SEO from Weaverse.
53+
if (data?.seo) {
54+
return getSeoMeta(data.seo as SeoConfig);
55+
}
56+
return getWeaverseSeoMeta(data?.weaverseData);
5057
};
5158
export default function Homepage() {
5259
return <WeaverseContent />;

app/routes/seo/sitemap-weaverse.xml.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function escapeXml(str: string): string {
1010
}
1111

1212
export async function loader({ request, context }: LoaderFunctionArgs) {
13-
const { weaverse } = context as any;
13+
const { weaverse } = context;
1414
const url = new URL(request.url);
1515
const baseUrl = `${url.protocol}//${url.host}`;
1616

app/sections/hero-video/index.tsx

Lines changed: 47 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Overlay } from "~/components/overlay";
88
import { ScrollReveal } from "~/components/scroll-reveal";
99
import { variants } from "./styles";
1010
import { type HeroVideoProps, SECTION_HEIGHTS } from "./types";
11-
import { calculateVideoHeight, getPlayerSize } from "./utils";
1211

1312
// react-player v3 is ESM-only and lazy-loads individual players internally,
1413
// so a plain dynamic import resolves cleanly. React.lazy here only defers the
@@ -37,29 +36,12 @@ export default function HeroVideo(props: HeroVideoProps) {
3736
...rest
3837
} = props;
3938

40-
const id = rest["data-wv-id"];
4139
const containerRef = useRef<HTMLDivElement>(null);
4240
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43-
const [size, setSize] = useState(() => getPlayerSize(id));
4441
const [playing, setPlaying] = useState(autoplay !== false);
4542
const [hovered, setHovered] = useState(false);
4643
const [hideContent, setHideContent] = useState(false);
4744

48-
// Calculate initial video height from intrinsic dimensions
49-
const [videoHeight, setVideoHeight] = useState<number | null>(() => {
50-
if (isBrowser && containerRef.current) {
51-
const containerWidth = containerRef.current.getBoundingClientRect().width;
52-
return calculateVideoHeight(video, containerWidth);
53-
}
54-
// Fallback: calculate from video metadata if available
55-
if (isBrowser && video?.width && video?.height) {
56-
// Use viewport width as estimate for container width
57-
const estimatedWidth = window.innerWidth;
58-
return calculateVideoHeight(video, estimatedWidth);
59-
}
60-
return null;
61-
});
62-
6345
// Content visible when: paused, or not hovered, or not hidden by delay
6446
let contentVisible = !playing || !hovered || !hideContent;
6547

@@ -85,10 +67,9 @@ export default function HeroVideo(props: HeroVideoProps) {
8567
setHideContent(false);
8668
}
8769

88-
const desktopHeight = SECTION_HEIGHTS[height] || `${heightOnDesktop}px`;
70+
const sectionHeight = SECTION_HEIGHTS[height] || `${heightOnDesktop}px`;
8971
const sectionStyle: CSSProperties = {
90-
"--desktop-height": desktopHeight,
91-
...(videoHeight ? { "--video-height": `${videoHeight}px` } : {}),
72+
"--section-height": sectionHeight,
9273
"--gap-desktop": `${gap ?? 0}px`,
9374
"--gap-mobile": (gap ?? 0) <= 20 ? `${gap ?? 0}px` : `${(gap ?? 0) / 2}px`,
9475
} as CSSProperties;
@@ -107,136 +88,59 @@ export default function HeroVideo(props: HeroVideoProps) {
10788
};
10889

10990
/**
110-
* Measure actual video element height and adjust container to match.
111-
* This corrects any discrepancy between calculated and actual rendered height.
91+
* Force muted + inline playback on the lazily-mounted <video> — required for
92+
* iOS to autoplay inline rather than going fullscreen. react-player mounts
93+
* the element asynchronously, so poll briefly until it appears. No height is
94+
* measured here: the container size is fixed in CSS and the video just covers
95+
* it (`object-cover`), so there is no measure→resize feedback loop.
11296
*/
113-
function syncVideoHeight() {
114-
if (!containerRef.current) {
97+
useEffect(() => {
98+
if (!isBrowser || !inView || !containerRef.current) {
11599
return;
116100
}
101+
const container = containerRef.current;
102+
let pollId: ReturnType<typeof setInterval> | null = null;
103+
let attempts = 0;
117104

118-
// Find the actual video or iframe element inside ReactPlayer
119-
const mediaEl = containerRef.current.querySelector(
120-
"video, iframe",
121-
) as HTMLElement | null;
122-
123-
if (mediaEl instanceof HTMLVideoElement) {
105+
function configure() {
106+
const mediaEl = container.querySelector("video");
107+
if (!mediaEl) {
108+
// Lazy player chunk / iframe embed not a <video> — retry briefly.
109+
attempts += 1;
110+
if (attempts > 100 && pollId) {
111+
clearInterval(pollId);
112+
}
113+
return;
114+
}
115+
if (pollId) {
116+
clearInterval(pollId);
117+
pollId = null;
118+
}
124119
mediaEl.muted = true;
125120
mediaEl.defaultMuted = true;
126121
mediaEl.playsInline = true;
127122
mediaEl.setAttribute("muted", "");
128123
mediaEl.setAttribute("playsinline", "");
129124
mediaEl.setAttribute("webkit-playsinline", "");
130-
131125
if (playing) {
132126
mediaEl.autoplay = true;
133127
mediaEl.setAttribute("autoplay", "");
134128
}
135-
136129
if (loop !== false) {
137130
mediaEl.loop = true;
138131
mediaEl.setAttribute("loop", "");
139132
}
140133
}
141134

142-
if (mediaEl instanceof HTMLVideoElement) {
143-
// Derive height from the INTRINSIC video dimensions, never from the
144-
// rendered box: the container height is set from this value, and the
145-
// video fills the container, so committing a rendered measurement can
146-
// deadlock a wrong value (e.g. the 300x150 default before metadata
147-
// loads — observed as an intermittently squashed hero).
148-
if (mediaEl.videoWidth > 0 && mediaEl.videoHeight > 0) {
149-
const containerWidth =
150-
containerRef.current.getBoundingClientRect().width;
151-
const intrinsicHeight =
152-
(containerWidth * mediaEl.videoHeight) / mediaEl.videoWidth;
153-
commitVideoHeight(intrinsicHeight);
154-
}
155-
// Metadata not loaded yet — skip; loadedmetadata/ResizeObserver will
156-
// re-trigger this sync.
157-
return;
158-
}
159-
if (mediaEl) {
160-
// Iframe embeds (YouTube/Vimeo) size themselves via aspect-ratio
161-
// styles — the rendered box is the only available signal.
162-
const actualHeight = mediaEl.getBoundingClientRect().height;
163-
if (actualHeight > 0) {
164-
commitVideoHeight(actualHeight);
165-
}
166-
}
167-
}
168-
function commitVideoHeight(next: number) {
169-
// Only update if significantly different (> 2px) to avoid jitter
170-
setVideoHeight((prev) => {
171-
if (prev === null || Math.abs(prev - next) > 2) {
172-
return next;
173-
}
174-
return prev;
175-
});
176-
}
135+
pollId = setInterval(configure, 100);
136+
configure();
177137

178-
/**
179-
* Recalculate video height on resize using intrinsic dimensions.
180-
* This ensures the container always matches the video's actual displayed size.
181-
*/
182-
function handleResize() {
183-
setSize(getPlayerSize(id));
184-
// First, try intrinsic calculation
185-
if (containerRef.current && video?.width && video?.height) {
186-
const containerWidth = containerRef.current.getBoundingClientRect().width;
187-
const calculatedHeight = calculateVideoHeight(video, containerWidth);
188-
if (calculatedHeight) {
189-
setVideoHeight(calculatedHeight);
190-
}
191-
}
192-
// Then sync with actual video element after a brief delay
193-
requestAnimationFrame(syncVideoHeight);
194-
}
195-
/**
196-
* A single post-mount measurement is a race: if it lands before the video
197-
* metadata loads, the <video> element reports the browser default 300x150
198-
* and the container gets locked at ~150px (intermittent squashed hero).
199-
* Watch for the media element (react-player mounts lazily) and keep the
200-
* container in sync with a ResizeObserver — metadata load, player chrome,
201-
* and breakpoint changes all resize the element and re-trigger the sync.
202-
*/
203-
function watchMediaElement(): (() => void) | undefined {
204-
if (!isBrowser || !containerRef.current) {
205-
return undefined;
206-
}
207-
let observer: ResizeObserver | null = null;
208-
let pollId: ReturnType<typeof setInterval> | null = null;
209-
let attempts = 0;
210-
const attach = () => {
211-
const mediaEl = containerRef.current?.querySelector("video, iframe");
212-
if (!mediaEl) {
213-
// Lazy player chunk not mounted yet — retry briefly.
214-
attempts += 1;
215-
if (attempts > 100 && pollId) {
216-
clearInterval(pollId);
217-
}
218-
return;
219-
}
220-
if (pollId) {
221-
clearInterval(pollId);
222-
pollId = null;
223-
}
224-
syncVideoHeight();
225-
observer = new ResizeObserver(() => syncVideoHeight());
226-
observer.observe(mediaEl);
227-
if (mediaEl instanceof HTMLVideoElement) {
228-
mediaEl.addEventListener("loadedmetadata", syncVideoHeight);
229-
}
230-
};
231-
pollId = setInterval(attach, 100);
232-
attach();
233138
return () => {
234139
if (pollId) {
235140
clearInterval(pollId);
236141
}
237-
observer?.disconnect();
238142
};
239-
}
143+
}, [inView, playing, loop]);
240144

241145
// Reset hideContent when video is paused (show content immediately)
242146
useEffect(() => {
@@ -258,17 +162,6 @@ export default function HeroVideo(props: HeroVideoProps) {
258162
};
259163
}, []);
260164

261-
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> --- IGNORE ---
262-
useEffect(() => {
263-
handleResize();
264-
window.addEventListener("resize", handleResize);
265-
const stopWatching = inView ? watchMediaElement() : undefined;
266-
return () => {
267-
window.removeEventListener("resize", handleResize);
268-
stopWatching?.();
269-
};
270-
}, [inView, height, heightOnDesktop]);
271-
272165
return (
273166
<ScrollReveal
274167
as="section"
@@ -282,12 +175,10 @@ export default function HeroVideo(props: HeroVideoProps) {
282175
onMouseEnter={handleMouseEnter}
283176
onMouseLeave={handleMouseLeave}
284177
className={clsx(
285-
"relative flex items-center justify-center overflow-hidden w-full",
286-
videoHeight
287-
? "h-(--video-height) md:h-[min(var(--desktop-height),var(--video-height))]"
288-
: "aspect-video md:aspect-auto md:h-(--desktop-height)",
289-
"md:w-[max(var(--desktop-height)/9*16,100vw)]",
290-
"md:translate-x-[min(0px,calc((var(--desktop-height)/9*16-100vw)/-2))]",
178+
// Full-bleed hero band: full width, fixed height. `container-type:size`
179+
// exposes the box to container-query units so the player below can
180+
// scale itself to cover the band (see its inline style).
181+
"relative w-full overflow-hidden h-(--section-height) @container-size",
291182
)}
292183
>
293184
{inView && (
@@ -299,12 +190,21 @@ export default function HeroVideo(props: HeroVideoProps) {
299190
muted
300191
loop={loop !== false}
301192
playsInline
302-
width={size.width}
303-
height={size.height}
304193
controls={false}
305-
onReady={() => {
306-
// Sync container height with actual video element after render
307-
requestAnimationFrame(syncVideoHeight);
194+
// Cover the band for ANY source. `object-fit: cover` only crops
195+
// native <video>; YouTube/Vimeo render an <iframe> in shadow DOM
196+
// that ignores it, so we instead size the player to the smallest
197+
// 16:9 box that still covers the container, then center it (the
198+
// band's `overflow-hidden` crops the excess). cqw/cqh resolve
199+
// against the band's own width/height.
200+
style={{
201+
position: "absolute",
202+
top: "50%",
203+
left: "50%",
204+
transform: "translate(-50%, -50%)",
205+
width: "max(100cqw, calc(100cqh * 16 / 9))",
206+
height: "max(100cqh, calc(100cqw * 9 / 16))",
207+
objectFit: "cover",
308208
}}
309209
/>
310210
</Suspense>

0 commit comments

Comments
 (0)