From 86100b2251a60c95593ce8dd9267a9ebbd129671 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 25 Oct 2026 12:04:35 +0530 Subject: [PATCH] perf(sites): optimize article and feed images through the host resolver (#71) The Image primitive rendered the original URL, so blog post, docs and feed images skipped next/image. It now uses the same resolver as block images, and article hero images load eagerly at high priority. The resolver lives on globalThis: /server and /primitives/server each bundle their own copy of the module, so registering it through one entry didn't reach the other. --- .../page-builder-image-primitive-optimizer.md | 5 +++++ apps/sites/components/themed-content-feed.tsx | 1 + apps/sites/components/themed-docs.tsx | 2 ++ apps/sites/components/themed-document.tsx | 2 ++ .../page-builder/src/components/image.tsx | 17 ++++++++++++--- .../page-builder/src/primitives/image.tsx | 21 ++++++++++++++++++- 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/page-builder-image-primitive-optimizer.md diff --git a/.changeset/page-builder-image-primitive-optimizer.md b/.changeset/page-builder-image-primitive-optimizer.md new file mode 100644 index 0000000..caa8dfc --- /dev/null +++ b/.changeset/page-builder-image-primitive-optimizer.md @@ -0,0 +1,5 @@ +--- +"@frontlit/page-builder": patch +--- + +The `Image` primitive now goes through the host's image resolver, like block images, so blog, docs and feed images are resized by the host's optimizer. It also accepts `priority` for above-the-fold images. The resolver is shared across the package's entry points. diff --git a/apps/sites/components/themed-content-feed.tsx b/apps/sites/components/themed-content-feed.tsx index 9428053..fdbe430 100644 --- a/apps/sites/components/themed-content-feed.tsx +++ b/apps/sites/components/themed-content-feed.tsx @@ -92,6 +92,7 @@ export async function ThemedArticleFeed({ src={imageUrl} alt={imageAlt} theme={theme} + sizes="(min-width: 1024px) 1024px, 100vw" className="aspect-[16/9] max-h-72 w-full object-cover transition-transform duration-300 group-hover:scale-105" /> diff --git a/apps/sites/components/themed-docs.tsx b/apps/sites/components/themed-docs.tsx index 902cc08..e7407e1 100644 --- a/apps/sites/components/themed-docs.tsx +++ b/apps/sites/components/themed-docs.tsx @@ -359,6 +359,8 @@ export async function ThemedDocArticle({ src={imageUrl} alt={imageAlt} theme={theme} + priority + sizes="(min-width: 1024px) 768px, 100vw" className="aspect-[2/1] max-h-[400px] w-full object-cover" /> diff --git a/apps/sites/components/themed-document.tsx b/apps/sites/components/themed-document.tsx index b345a68..df95697 100644 --- a/apps/sites/components/themed-document.tsx +++ b/apps/sites/components/themed-document.tsx @@ -46,6 +46,8 @@ export async function ThemedBlogPost({ src={imageUrl} alt={imageAlt} theme={theme} + priority + sizes="(min-width: 1024px) 1024px, 100vw" className="aspect-[2/1] max-h-[480px] w-full object-cover" /> diff --git a/packages/page-builder/src/components/image.tsx b/packages/page-builder/src/components/image.tsx index 68ca2ed..3fdd260 100644 --- a/packages/page-builder/src/components/image.tsx +++ b/packages/page-builder/src/components/image.tsx @@ -18,14 +18,25 @@ export type ImageAttributesResolver = ( input: ImageAttributesInput, ) => React.ImgHTMLAttributes | null; -let imageAttributesResolver: ImageAttributesResolver | null = null; +// Kept on `globalThis`: each package entry (`/server`, `/primitives/server`) +// bundles its own copy of this module, and they must share one resolver. +const registry = globalThis as { + __frontlitImageResolver?: ImageAttributesResolver | null; +}; /** * Registers the host's resolver. Call once at server start-up; renders that * run without one emit the original URL. */ export function setImageAttributesResolver(resolver: ImageAttributesResolver | null) { - imageAttributesResolver = resolver; + registry.__frontlitImageResolver = resolver; +} + +/** The registered resolver's attributes for an image, if any. */ +export function resolveImageAttributes( + input: ImageAttributesInput, +): React.ImgHTMLAttributes | null { + return registry.__frontlitImageResolver?.(input) ?? null; } export type ImageObjectFit = "cover" | "contain" | "fill" | "none" | "scale-down"; @@ -106,7 +117,7 @@ export const Image = (props: ImgProps) => { ); } - const optimized = imageAttributesResolver?.({ src: source, alt, sizes, priority }); + const optimized = resolveImageAttributes({ src: source, alt, sizes, priority }); return (
{ @@ -7,6 +8,8 @@ export interface ImageProps extends React.ImgHTMLAttributes { alt?: string; className?: string; theme?: ThemeStyle; + /** Above-the-fold image (e.g. LCP): fetched eagerly at high priority. */ + priority?: boolean; } export const Image: React.FC = ({ @@ -14,10 +17,26 @@ export const Image: React.FC = ({ alt = "", className = "", theme, + priority = false, + sizes, ...props }) => { const cardStyles = theme?.interactives?.card; const classes = cn("w-full object-cover", cardStyles?.border?.radius, className); + // The host's optimizer (if registered) serves a resized, modern-format copy. + const optimized = resolveImageAttributes({ src, alt, sizes, priority }); - return {alt}; + return ( + {alt} + ); };