Skip to content

Instantly share code, notes, and snippets.

@0x15f
Last active November 7, 2024 02:46
Show Gist options
  • Save 0x15f/889d4ede331149491ab9189f18cdc0ab to your computer and use it in GitHub Desktop.
Save 0x15f/889d4ede331149491ab9189f18cdc0ab to your computer and use it in GitHub Desktop.
Bundled section rendering

Usage

import { prefetchSection, loadSection } from "./section-rendering";

// hover / mouseover
function variantHover(newVariantId: number) {
  prefetchSection("product-form", `/products/current-product`, newVariantId);
}

function variantChange(newVariantId: number) {
  document.startViewTransition(() => {
    loadSection("product-form", `/products/current-product`, newVariantId).then(
      (content) => {
        const range = document.createRange();
        const documentFragment = range.createContextualFragment(content);
        
        // use the new fragment e.g) myElement.replaceWith
        // or you can go further and use something like `aralroca/diff-dom-streaming` to diff and only update whats changed
      }
    );
  });
}
const isDataSavingMode = navigator?.connection?.saveData
type SectionQueryParams = URLSearchParams | Record<string, string | number | boolean | null>
function convertSectionQueryParamsToURLSearchParams(queryParams: SectionQueryParams) {
if (queryParams instanceof URLSearchParams) return queryParams
else {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(queryParams)) {
if (value === null) continue
params.set(key, value.toString())
}
return params
}
}
interface FetchCallbacks {
resolve: (data?: string) => void
reject: (error: any) => void
}
class SectionLoader {
private pendingRequests = new Map<string, FetchCallbacks[]>()
private prefetchRequests = new Set<string>()
private prefetchCache = new Map<string, string>()
private ongoingFetches = new Set<string>()
private fetchSectionsTimeout: ReturnType<typeof setTimeout> | null = null
private prefetchSectionsTimeout: ReturnType<typeof setTimeout> | null = null
private cacheMaxSize = 100
private buildCacheKey(sectionId: string, path: string, queryParams?: SectionQueryParams): string {
return JSON.stringify({
path: path || window.location.pathname,
sectionId,
queryParams: queryParams ? convertSectionQueryParamsToURLSearchParams(queryParams).toString() : '',
})
}
private buildRequestGroups(sectionKeys: string[]) {
const groups = sectionKeys.reduce(
(acc, key) => {
const { path, sectionId, queryParams } = JSON.parse(key)
const groupKey = JSON.stringify({
path,
queryParams,
})
const params = queryParams ? new URLSearchParams(queryParams) : undefined
if (!acc[groupKey]) {
acc[groupKey] = {
path,
queryParams: params,
keys: [],
sectionIds: [],
}
}
acc[groupKey].keys.push(key)
acc[groupKey].sectionIds.push(sectionId)
return acc
},
{} as Record<
string,
{
path: string
queryParams?: URLSearchParams
keys: string[]
sectionIds: string[]
}
>,
)
return Object.values(groups)
}
private scheduleFetchSections() {
if (this.fetchSectionsTimeout) {
clearTimeout(this.fetchSectionsTimeout)
}
this.fetchSectionsTimeout = setTimeout(() => this.fetchSections(), 50)
}
private schedulePrefetchSections() {
if (this.prefetchSectionsTimeout) {
clearTimeout(this.prefetchSectionsTimeout)
}
this.prefetchSectionsTimeout = setTimeout(() => this.prefetchSections(), 50)
}
private async fetchSections() {
if (this.pendingRequests.size === 0) {
return
}
const sectionKeys = Array.from(this.pendingRequests.keys())
const requestGroups = this.buildRequestGroups(sectionKeys)
await Promise.all(
requestGroups.map(async ({ path, queryParams, keys, sectionIds }) => {
try {
const url = new URL(window.location.href)
url.pathname = path || window.location.pathname
if (queryParams) {
url.search = queryParams.toString()
}
url.searchParams.set('sections', sectionIds.join(','))
const requestUrl = url.toString()
const response = await fetch(requestUrl)
if (!response.ok) {
throw new Error(`Failed to fetch: ${requestUrl}`)
}
const sectionsData = await response.json()
keys.forEach((key, index) => {
const callbacks = this.pendingRequests.get(key) || []
this.pendingRequests.delete(key)
const sectionId = sectionIds[index]
const data = sectionsData[sectionId]
if (data === undefined) {
callbacks.forEach(({ reject }) => reject(new Error(`No data for section ${sectionId}`)))
} else {
callbacks.forEach(({ resolve }) => resolve(data))
this.addToCache(key, data)
}
this.ongoingFetches.delete(key)
})
} catch (error) {
console.error(error)
keys.forEach((key) => {
const callbacks = this.pendingRequests.get(key) || []
this.pendingRequests.delete(key)
callbacks.forEach(({ reject }) => reject(error))
this.ongoingFetches.delete(key)
})
}
}),
)
}
private async prefetchSections() {
if (this.prefetchRequests.size === 0) {
return
}
const sectionKeys = Array.from(this.prefetchRequests)
const requestGroups = this.buildRequestGroups(sectionKeys)
await Promise.all(
requestGroups.map(async ({ path, queryParams, keys, sectionIds }) => {
try {
const url = new URL(window.location.href)
url.pathname = path || window.location.pathname
if (queryParams) {
url.search = queryParams.toString()
}
url.searchParams.set('sections', sectionIds.join(','))
const requestUrl = url.toString()
const response = await fetch(requestUrl, {
priority: 'low',
})
if (!response.ok) {
throw new Error(`Failed to prefetch: ${requestUrl}`)
}
const sectionsData = await response.json()
keys.forEach((key, index) => {
const sectionId = sectionIds[index]
const data = sectionsData[sectionId]
if (data !== undefined) {
this.addToCache(key, data)
}
this.prefetchRequests.delete(key)
this.ongoingFetches.delete(key)
const callbacks = this.pendingRequests.get(key) || []
this.pendingRequests.delete(key)
callbacks.forEach(({ resolve, reject }) => {
if (data !== undefined) {
resolve(data)
} else {
reject(new Error(`No data for section ${sectionId}`))
}
})
})
} catch (error) {
console.error(error)
keys.forEach((key) => {
this.prefetchRequests.delete(key)
this.ongoingFetches.delete(key)
const callbacks = this.pendingRequests.get(key) || []
this.pendingRequests.delete(key)
callbacks.forEach(({ reject }) => reject(error))
})
}
}),
)
}
private addToCache(key: string, data: string) {
if (this.prefetchCache.size > this.cacheMaxSize) {
this.prefetchCache.delete(this.prefetchCache.keys().next().value)
}
this.prefetchCache.set(key, data)
}
public loadSection(
sectionId: string,
path: string = '',
queryParams?: SectionQueryParams,
): Promise<string | undefined> {
const key = this.buildCacheKey(sectionId, path, queryParams)
if (this.prefetchCache.has(key)) {
return Promise.resolve(this.prefetchCache.get(key))
}
return new Promise((resolve, reject) => {
if (!this.pendingRequests.has(key)) {
this.pendingRequests.set(key, [{ resolve, reject }])
}
if (!this.ongoingFetches.has(key) && !this.prefetchRequests.has(key)) {
this.ongoingFetches.add(key)
this.scheduleFetchSections()
}
})
}
public prefetchSection(sectionId: string, path: string = '', queryParams?: SectionQueryParams) {
if (isDataSavingMode) {
return
}
const key = this.buildCacheKey(sectionId, path, queryParams)
if (
this.pendingRequests.has(key) ||
this.ongoingFetches.has(key) ||
this.prefetchCache.has(key) ||
this.prefetchRequests.has(key)
) {
return
}
this.prefetchRequests.add(key)
this.ongoingFetches.add(key)
this.schedulePrefetchSections()
}
public cancelPrefetchSection(sectionId: string, path: string = '', queryParams?: SectionQueryParams) {
this.prefetchRequests.delete(this.buildCacheKey(sectionId, path, queryParams))
if (this.prefetchRequests.size) {
this.schedulePrefetchSections()
}
}
}
const sectionLoader = new SectionLoader()
export const loadSection = sectionLoader.loadSection.bind(sectionLoader)
export const prefetchSection = sectionLoader.prefetchSection.bind(sectionLoader)
export const cancelPrefetchSection = sectionLoader.cancelPrefetchSection.bind(sectionLoader)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment