Skip to content

Commit 776d103

Browse files
committed
feat : add iframe sw caching
1 parent aa59fbb commit 776d103

File tree

6 files changed

+404
-26
lines changed

6 files changed

+404
-26
lines changed

app/layout.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from 'next/font/google'
33
import './globals.css'
44
import { ThemeProvider } from '@/components/theme-provider'
55
import { GoogleAnalytics } from '@next/third-parties/google'
6+
import ServiceWorkerInit from '@/components/ServiceWorkerInit'
67

78
const geistSans = Geist({
89
variable: '--font-geist-sans',
@@ -15,7 +16,7 @@ const geistMono = Geist_Mono({
1516
})
1617

1718
export const metadata: Metadata = {
18-
title: 'Shadcn Blocks',
19+
title: 'Shadcn Marketing Blocks',
1920
description: 'Speed up your workflow with responsive, pre-built UI blocks designed for marketing websites.',
2021
}
2122

@@ -27,9 +28,14 @@ export default function RootLayout({
2728
return (
2829
<html lang="en">
2930
<body className={`${geistSans.variable} ${geistMono.variable} overflow-x-hidden antialiased`}>
30-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
31+
<ThemeProvider
32+
attribute="class"
33+
defaultTheme="system"
34+
enableSystem
35+
disableTransitionOnChange>
3136
{children}
3237
</ThemeProvider>
38+
<ServiceWorkerInit />
3339
</body>
3440
<GoogleAnalytics gaId="G-6KY6TLKXKY" />
3541
</html>

components/BlockPreview.tsx

Lines changed: 137 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { cn, titleToNumber } from '@/lib/utils'
1313
import CodeBlock from './code-block'
1414
import Link from 'next/link'
1515
import { OpenInV0Button } from './open-in-v0'
16+
import { isUrlCached } from '@/lib/serviceWorker'
1617

1718
export interface BlockPreviewProps {
1819
code?: string
@@ -29,11 +30,17 @@ const SMSIZE = 30
2930
const MDSIZE = 62
3031
const LGSIZE = 82
3132

33+
// Add this key function to generate cache keys
34+
const getCacheKey = (src: string) => `iframe-cache-${src}`
35+
3236
export const BlockPreview: React.FC<BlockPreviewProps> = ({ code, preview, title, category, previewOnly }) => {
3337
const [width, setWidth] = useState(DEFAULTSIZE)
3438
const [mode, setMode] = useState<'preview' | 'code'>('preview')
3539
const [iframeHeight, setIframeHeight] = useState(0)
3640
const [isLoading, setIsLoading] = useState(true)
41+
const [shouldLoadIframe, setShouldLoadIframe] = useState(false)
42+
const [cachedHeight, setCachedHeight] = useState<number | null>(null)
43+
const [isIframeCached, setIsIframeCached] = useState(false)
3744

3845
const terminalCode = `pnpm dlx shadcn@canary add https://nsui.irung.me/r/${category}-${titleToNumber(title)}.json`
3946

@@ -44,20 +51,120 @@ export const BlockPreview: React.FC<BlockPreviewProps> = ({ code, preview, title
4451
const isLarge = useMedia('(min-width: 1024px)')
4552

4653
const iframeRef = useRef<HTMLIFrameElement>(null)
54+
const observer = useRef<IntersectionObserver | null>(null)
55+
const blockRef = useRef<HTMLDivElement>(null)
56+
57+
// Set up Intersection Observer to load iframe when it comes into view
58+
useEffect(() => {
59+
observer.current = new IntersectionObserver(
60+
(entries) => {
61+
if (entries[0].isIntersecting) {
62+
setShouldLoadIframe(true)
63+
observer.current?.disconnect()
64+
}
65+
},
66+
{ threshold: 0.1 }
67+
)
68+
69+
if (blockRef.current) {
70+
observer.current.observe(blockRef.current)
71+
}
72+
73+
return () => {
74+
observer.current?.disconnect()
75+
}
76+
}, [])
77+
78+
// Check if the iframe content is already cached
79+
useEffect(() => {
80+
// Check if the iframe content is already cached by service worker
81+
const checkCache = async () => {
82+
try {
83+
const isCached = await isUrlCached(preview)
84+
setIsIframeCached(isCached)
85+
if (isCached) {
86+
// If cached by service worker, we can load it immediately
87+
setShouldLoadIframe(true)
88+
}
89+
} catch (error) {
90+
console.error('Error checking cache status:', error)
91+
}
92+
}
93+
94+
checkCache()
95+
96+
// Also check localStorage for cached height
97+
try {
98+
const cacheKey = getCacheKey(preview)
99+
const cached = localStorage.getItem(cacheKey)
100+
if (cached) {
101+
const { height, timestamp } = JSON.parse(cached)
102+
// Use cached height if it's less than 24 hours old
103+
const now = Date.now()
104+
if (now - timestamp < 24 * 60 * 60 * 1000) {
105+
setCachedHeight(height)
106+
setIframeHeight(height)
107+
// Still load the iframe, but we can show the correct height immediately
108+
}
109+
}
110+
} catch (error) {
111+
console.error('Error retrieving cache:', error)
112+
}
113+
}, [preview])
47114

115+
// Setup iframe load handler and height caching
48116
useEffect(() => {
49117
const iframe = iframeRef.current
118+
if (!iframe || !shouldLoadIframe) return
119+
50120
const handleLoad = () => {
51121
setIsLoading(false)
52-
const contentHeight = iframe!.contentWindow!.document.body.scrollHeight
53-
setIframeHeight(contentHeight)
122+
123+
try {
124+
const contentHeight = iframe.contentWindow!.document.body.scrollHeight
125+
setIframeHeight(contentHeight)
126+
127+
// Cache the height in localStorage
128+
const cacheKey = getCacheKey(preview)
129+
const cacheValue = JSON.stringify({
130+
height: contentHeight,
131+
timestamp: Date.now(),
132+
})
133+
localStorage.setItem(cacheKey, cacheValue)
134+
} catch (e) {
135+
console.error('Error accessing iframe content:', e)
136+
}
54137
}
55138

56-
iframe!.addEventListener('load', handleLoad)
139+
iframe.addEventListener('load', handleLoad)
57140
return () => {
58-
iframe!.removeEventListener('load', handleLoad)
141+
iframe.removeEventListener('load', handleLoad)
59142
}
60-
}, [])
143+
}, [shouldLoadIframe, preview])
144+
145+
// Add preload link for the iframe source when it's likely to be needed soon
146+
useEffect(() => {
147+
if (!blockRef.current || shouldLoadIframe) return
148+
149+
// Create a preload link for the iframe content
150+
const linkElement = document.createElement('link')
151+
linkElement.rel = 'preload'
152+
linkElement.href = preview
153+
linkElement.as = 'document'
154+
155+
// Only add if not already in the document
156+
if (!document.head.querySelector(`link[rel="preload"][href="${preview}"]`)) {
157+
document.head.appendChild(linkElement)
158+
}
159+
160+
return () => {
161+
// Clean up the preload link when component unmounts or iframe loads
162+
const existingLink = document.head.querySelector(`link[rel="preload"][href="${preview}"]`)
163+
if (existingLink) {
164+
document.head.removeChild(existingLink)
165+
}
166+
}
167+
}, [preview, shouldLoadIframe])
61168

62169
return (
63170
<section className="group mb-16 border-b [--color-border:color-mix(in_oklab,var(--color-zinc-200)_75%,transparent)] dark:[--color-border:color-mix(in_oklab,var(--color-zinc-800)_60%,transparent)]">
@@ -194,25 +301,31 @@ export const BlockPreview: React.FC<BlockPreviewProps> = ({ code, preview, title
194301
defaultSize={DEFAULTSIZE}
195302
minSize={SMSIZE}
196303
className="h-fit border-x">
197-
<iframe
198-
key={`${category}-${title}-iframe`}
199-
loading="lazy"
200-
allowFullScreen
201-
ref={iframeRef}
202-
title={title}
203-
height={iframeHeight}
204-
className="h-(--iframe-height) @starting:opacity-0 @starting:blur-xl block min-h-56 w-full duration-200 will-change-auto"
205-
src={preview}
206-
id={`block-${title}`}
207-
style={{ '--iframe-height': `${iframeHeight}px` } as React.CSSProperties}
208-
{...{ fetchPriority: 'low' }}
209-
/>
210-
211-
{isLoading && (
212-
<div className="bg-background absolute inset-0 right-2 flex items-center justify-center border-x">
213-
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
214-
</div>
215-
)}
304+
<div ref={blockRef}>
305+
{shouldLoadIframe ? (
306+
<iframe
307+
key={`${category}-${title}-iframe`}
308+
loading={isIframeCached ? 'eager' : 'lazy'}
309+
allowFullScreen
310+
ref={iframeRef}
311+
title={title}
312+
height={cachedHeight || iframeHeight}
313+
className={cn('h-(--iframe-height) block min-h-56 w-full duration-200 will-change-auto', !cachedHeight && '@starting:opacity-0 @starting:blur-xl', isIframeCached && '!opacity-100 !blur-none')}
314+
src={preview}
315+
id={`block-${title}`}
316+
style={
317+
{
318+
'--iframe-height': `${cachedHeight || iframeHeight}px`,
319+
display: 'block',
320+
} as React.CSSProperties
321+
}
322+
/>
323+
) : (
324+
<div className="flex min-h-56 items-center justify-center">
325+
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
326+
</div>
327+
)}
328+
</div>
216329
</Panel>
217330

218331
{isLarge && (

components/ServiceWorkerInit.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { registerServiceWorker } from '@/lib/serviceWorker'
5+
6+
export default function ServiceWorkerInit() {
7+
useEffect(() => {
8+
registerServiceWorker()
9+
}, [])
10+
11+
// This is a hidden component that just handles service worker registration
12+
return null
13+
}

components/dev/CacheControl.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { clearIframeCache, updateServiceWorker } from '@/lib/serviceWorker'
5+
import { Button } from '../ui/button'
6+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../ui/card'
7+
8+
export function CacheControl() {
9+
const [isClearing, setIsClearing] = useState(false)
10+
11+
const handleClearCache = () => {
12+
setIsClearing(true)
13+
try {
14+
// Clear localStorage cache
15+
for (let i = 0; i < localStorage.length; i++) {
16+
const key = localStorage.key(i)
17+
if (key && key.startsWith('iframe-cache-')) {
18+
localStorage.removeItem(key)
19+
}
20+
}
21+
22+
// Clear service worker cache
23+
clearIframeCache()
24+
25+
// Update service worker
26+
updateServiceWorker()
27+
28+
// Show success for a moment
29+
setTimeout(() => {
30+
setIsClearing(false)
31+
}, 1000)
32+
} catch (error) {
33+
console.error('Error clearing cache:', error)
34+
setIsClearing(false)
35+
}
36+
}
37+
38+
return (
39+
<Card className="fixed bottom-4 right-4 z-50 w-80 shadow-lg">
40+
<CardHeader className="pb-2">
41+
<CardTitle className="text-sm">Developer Controls</CardTitle>
42+
<CardDescription>Manage iframe caching</CardDescription>
43+
</CardHeader>
44+
<CardContent>
45+
<p className="text-muted-foreground text-xs">Clear iframe caches to see the latest content. This will remove both browser and service worker caches.</p>
46+
</CardContent>
47+
<CardFooter>
48+
<Button
49+
size="sm"
50+
variant="destructive"
51+
onClick={handleClearCache}
52+
disabled={isClearing}>
53+
{isClearing ? 'Clearing...' : 'Clear All Iframe Caches'}
54+
</Button>
55+
</CardFooter>
56+
</Card>
57+
)
58+
}

lib/serviceWorker.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Service worker registration and management utilities
3+
*/
4+
5+
// Register the service worker
6+
export function registerServiceWorker() {
7+
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
8+
window.addEventListener('load', () => {
9+
navigator.serviceWorker
10+
.register('/sw.js')
11+
.then(registration => {
12+
console.log('SW registered: ', registration);
13+
})
14+
.catch(registrationError => {
15+
console.log('SW registration failed: ', registrationError);
16+
});
17+
});
18+
}
19+
}
20+
21+
// Send a message to the service worker
22+
export function sendMessageToSW(message: any) {
23+
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && navigator.serviceWorker.controller) {
24+
navigator.serviceWorker.controller.postMessage(message);
25+
}
26+
}
27+
28+
// Clear iframe cache for a specific URL or all iframe caches if no URL provided
29+
export function clearIframeCache(url?: string) {
30+
sendMessageToSW({
31+
type: 'CLEAR_IFRAME_CACHE',
32+
url
33+
});
34+
}
35+
36+
// Update the service worker
37+
export function updateServiceWorker() {
38+
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
39+
navigator.serviceWorker.ready.then(registration => {
40+
registration.update();
41+
});
42+
}
43+
}
44+
45+
// Check if a URL is already cached by the service worker
46+
export async function isUrlCached(url: string): Promise<boolean> {
47+
if (typeof window === 'undefined' || !('caches' in window)) {
48+
return false;
49+
}
50+
51+
try {
52+
const cache = await caches.open('cnblocks-iframe-cache-v1');
53+
const cachedResponse = await cache.match(url);
54+
return cachedResponse !== undefined;
55+
} catch (error) {
56+
console.error('Error checking cache:', error);
57+
return false;
58+
}
59+
}

0 commit comments

Comments
 (0)