Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@
transform: scale(0.8);
}
}

/*
* CSS containment for repeated, self-contained subtrees (grid cards, list
* rows). `layout` tells the browser that content inside the element can't
* affect layout outside it and vice versa, so adding/removing/animating
* one card doesn't force a layout recalculation of its siblings or
* ancestors; `style` scopes counters. Deliberately omits `paint` (which
* also clips overflow, like `overflow: hidden`) so it stays safe to apply
* even where a child intentionally overflows the box (tooltips, dropdown
* menus). See docs/css-containment-usage.md.
*
* Unsupported browsers (Safari < 15.4) ignore the `contain` declaration
* entirely — layout is unaffected, so no fallback is needed.
*/
.contain-layout-style {
contain: layout style;
}
}

/* Skeleton shimmer animation — used by components/ui/Skeleton.tsx (#871) */
Expand Down
53 changes: 53 additions & 0 deletions app/hooks/useWillChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

/**
* Scopes `will-change` to the lifetime of an animation instead of leaving it
* set permanently.
*
* Leaving `will-change` on an element forces the browser to keep it
* promoted to its own compositor layer indefinitely, which costs GPU memory
* for no benefit once the animation is done. This hook sets `will-change`
* on mount (right as a one-shot enter animation — e.g. a Tailwind
* `animate-in` class — starts) and clears it as soon as the
* animation/transition ends, with a timeout fallback in case neither event
* fires (the animation is interrupted, or the element has none at all).
*
* For repeated, hover-triggered transforms (a card that scales on
* `:hover`), prefer the CSS-only pattern instead — add a
* `hover:will-change-transform` / `group-hover:will-change-transform`
* class so the browser only promotes the layer while the pointer is
* actually over it. See docs/will-change-guidelines.md.
*/

import { useEffect, useRef } from "react";

export function useWillChange<T extends HTMLElement>(
properties: string,
{ timeoutMs = 1000 }: { timeoutMs?: number } = {},
) {
const ref = useRef<T>(null);

useEffect(() => {
const node = ref.current;
if (!node) return;

node.style.willChange = properties;

const clear = () => {
node.style.willChange = "auto";
};

node.addEventListener("animationend", clear);
node.addEventListener("transitionend", clear);
const timeoutId = window.setTimeout(clear, timeoutMs);

return () => {
node.removeEventListener("animationend", clear);
node.removeEventListener("transitionend", clear);
window.clearTimeout(timeoutId);
clear();
};
}, [properties, timeoutMs]);

return ref;
}
106 changes: 58 additions & 48 deletions app/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from "@/app/lib/logger";
import { scheduleWork } from "@/app/lib/mainThreadOptimization";

/**
* Analytics Tracking Utility
Expand Down Expand Up @@ -269,32 +270,37 @@ class Analytics {
return;
}

const sanitizedPath = this.sanitize(path);
this.log('Page view:', sanitizedPath);

try {
switch (this.provider) {
case 'ga4':
if (window.gtag) {
window.gtag('event', 'page_view', {
page_path: sanitizedPath,
});
}
break;

case 'plausible':
if (window.plausible) {
window.plausible('pageview', { props: { path: sanitizedPath } });
}
break;

case 'custom':
this.sendCustomEvent('page_view', { path: sanitizedPath });
break;
// PII sanitization and provider dispatch aren't needed for this frame to
// paint, so they run at idle time instead of on the click/navigation
// that triggered them.
scheduleWork(() => {
const sanitizedPath = this.sanitize(path);
this.log('Page view:', sanitizedPath);

try {
switch (this.provider) {
case 'ga4':
if (window.gtag) {
window.gtag('event', 'page_view', {
page_path: sanitizedPath,
});
}
break;

case 'plausible':
if (window.plausible) {
window.plausible('pageview', { props: { path: sanitizedPath } });
}
break;

case 'custom':
this.sendCustomEvent('page_view', { path: sanitizedPath });
break;
}
} catch (error) {
logger.error('Failed to track page view:', error);
}
} catch (error) {
logger.error('Failed to track page view:', error);
}
}, 'background');
}

/**
Expand All @@ -308,30 +314,34 @@ class Analytics {
return;
}

const sanitizedProperties = properties ? this.sanitize(properties) : {};
this.log('Event:', name, sanitizedProperties);

try {
switch (this.provider) {
case 'ga4':
if (window.gtag) {
window.gtag('event', name, sanitizedProperties);
}
break;

case 'plausible':
if (window.plausible) {
window.plausible(name, { props: sanitizedProperties });
}
break;

case 'custom':
this.sendCustomEvent(name, sanitizedProperties);
break;
// Same reasoning as trackPageView: sanitization + dispatch is non-critical
// and shouldn't run in the same task as the interaction that fired it.
scheduleWork(() => {
const sanitizedProperties = properties ? this.sanitize(properties) : {};
this.log('Event:', name, sanitizedProperties);

try {
switch (this.provider) {
case 'ga4':
if (window.gtag) {
window.gtag('event', name, sanitizedProperties);
}
break;

case 'plausible':
if (window.plausible) {
window.plausible(name, { props: sanitizedProperties });
}
break;

case 'custom':
this.sendCustomEvent(name, sanitizedProperties);
break;
}
} catch (error) {
logger.error('Failed to track event:', error);
}
} catch (error) {
logger.error('Failed to track event:', error);
}
}, 'background');
}

/**
Expand Down
72 changes: 71 additions & 1 deletion app/lib/imageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* - ImageLoadingState type consumed by load/error handlers
*
* All exports are safe to use in both Server Components and Client Components.
* Canvas-based helpers are guarded with `typeof window !== 'undefined'`.
* Canvas-based helpers are guarded with `typeof window !== 'undefined'`. The
* `*Async` variants render via OffscreenCanvas in a worker instead of the
* main-thread canvas; see docs/offscreen-canvas-usage.md.
*/

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -201,3 +203,71 @@ export function getBlurPlaceholder(
blurCache.set(imageUrl, placeholder);
return placeholder;
}

// ─── OffscreenCanvas-based placeholder (client-only, off main thread) ────────
//
// Same gradient + encode as generateBlurPlaceholder above, but run inside a
// worker via OffscreenCanvas so drawing and JPEG encoding never block the
// main thread. Falls back to the synchronous canvas implementation where
// OffscreenCanvas or Worker aren't available (Safari < 16.4, SSR, jsdom).

let blurWorker: Worker | null = null;
let blurRequestId = 0;
const pendingBlurRequests = new Map<number, (dataUrl: string) => void>();

function supportsOffscreenCanvas(): boolean {
return (
typeof window !== "undefined" &&
typeof Worker !== "undefined" &&
typeof OffscreenCanvas !== "undefined"
);
}

function getBlurWorker(): Worker {
if (!blurWorker) {
blurWorker = new Worker(new URL("../workers/blurPlaceholder.worker.ts", import.meta.url));
blurWorker.onmessage = (event: MessageEvent<{ requestId: number; dataUrl: string }>) => {
const resolve = pendingBlurRequests.get(event.data.requestId);
if (!resolve) return;
resolve(event.data.dataUrl);
pendingBlurRequests.delete(event.data.requestId);
};
}
return blurWorker;
}

/**
* Generate a tiny gradient placeholder off the main thread using
* OffscreenCanvas. Falls back to `generateBlurPlaceholder` (synchronous,
* main-thread canvas) where OffscreenCanvas/Worker support is missing.
*/
export function generateBlurPlaceholderAsync(width = 10, height = 10): Promise<string> {
if (typeof window === "undefined") return Promise.resolve(DEFAULT_BLUR_PLACEHOLDER);
if (!supportsOffscreenCanvas()) return Promise.resolve(generateBlurPlaceholder(width, height));

const worker = getBlurWorker();
const requestId = ++blurRequestId;

return new Promise((resolve) => {
pendingBlurRequests.set(requestId, resolve);
worker.postMessage({ requestId, width, height });
});
}

/**
* Get or generate a cached blur placeholder keyed by image URL, generating
* off the main thread via `generateBlurPlaceholderAsync`.
* Client-only — resolves to DEFAULT_BLUR_PLACEHOLDER on the server.
*/
export async function getBlurPlaceholderAsync(
imageUrl: string,
width = 10,
height = 10,
): Promise<string> {
if (typeof window === "undefined") return DEFAULT_BLUR_PLACEHOLDER;
if (blurCache.has(imageUrl)) return blurCache.get(imageUrl)!;

const placeholder = await generateBlurPlaceholderAsync(width, height);
blurCache.set(imageUrl, placeholder);
return placeholder;
}
Loading