This document provides a top-level view of
@supuwoerc/masonry's architectural design, threading model, design patterns, and key technical decisions.
This is the top-level architecture document, helping you understand why the library is designed this way and how the parts collaborate.
Bottlenecks of traditional DOM/Canvas approaches:
drawImage calls when rendering many images cause frame rate dropsThis library's solution moves all rendering-intensive work to the Worker thread:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Main Thread │ │ Worker Thread │
│ │ │ │
│ • Event listeners │ │ • Layout calculation │
│ (scroll/click) │ │ • Canvas rendering │
│ • Image resource loading │ │ • Inertia scrolling physics │
│ • Placeholder generation │ │ • Viewport culling │
│ • ResizeObserver │ │ • Hit detection (click) │
│ • Message routing │ │ • Seamless loop computation │
│ • Lifecycle management │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
Implemented via postMessage + Transferable objects:
Message<T> structure#initWorker() demonstrates the core logic of OffscreenCanvas transfer and items normalization:
// src/core/masonry.ts
async #initWorker() {
try {
this.#worker = new Worker(new URL('./worker/offscreen-canvas.ts', import.meta.url), {
type: 'module',
})
const canvas = this.#config.core.canvas
// Critical: after transferControlToOffscreen, all rendering ops on this canvas from main thread become invalid
const offscreenCanvas = canvas.transferControlToOffscreen()
// ─── Build SetupPayload: only pass serializable pure data ───
const payload: SetupPayload = {
offscreenCanvas,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
config: { core: { backgroundColor, style, layout, limit, timeout } },
dpr: window.devicePixelRatio || 1,
}
// ─── Items normalization: different processing paths for three input formats ───
const items = this.#config.core.items
if (items?.length) {
if (items[0] instanceof ImageBitmap) {
// Path 1: Pre-loaded ImageBitmap → pass directly to Worker
payload.config.core.items = items as ImageBitmap[]
} else {
// Path 2: URL strings or ItemDescriptor → only pass count and dimensions
const descriptors = this.#normalizeItems(items as string[] | ItemDescriptor[])
payload.config.core.itemCount = descriptors.length
payload.config.core.itemSizes = descriptors.map((d) => ({ width: d.width, height: d.height }))
// URLs stay on main thread, loaded later by ImageLoader
this.#pendingUrls = descriptors
}
}
// OffscreenCanvas transferred as Transferable (zero-copy; main thread reference invalidated)
this.#sendMessage(MessageType.Setup, payload, [offscreenCanvas])
} catch (error) {
// Graceful degradation when Worker creation fails
this.#useWorker = false
this.#worker = null
this.onError(error)
}
}
Design Notes:
ImageBitmap can be directly transferred to Worker via Transferable (zero-copy), while URL strings cannot be serialized into render data Worker needs — they must stay on the main thread for async loading and per-image delivery.onClick, loadMore, onReady and other function references are non-serializable; only pure data config is sent to Worker.#useWorker = false and notifies user via onError.| Approach | Pros | Cons |
|---|---|---|
| DOM manipulation | Simple & intuitive | Reflow/repaint expensive, 10K+ elements infeasible |
| Main-thread Canvas | No DOM overhead | Rendering still blocks main thread |
| OffscreenCanvas | Rendering never blocks main thread | Requires Worker communication overhead |
Key code:
// src/core/masonry.ts:160
const offscreenCanvas = canvas.transferControlToOffscreen()
File: src/core/builder.ts
Provides a fluent API to reduce configuration complexity:
const masonry = new MasonryBuilder()
.withCore({ canvas, style: { width: 200, height: 300 } })
.withInteraction({ onClick: (e) => console.log(e) })
.withLoader({ pageSize: 20, loadMore: fetchImages })
.build()
Design intent:
with* method provides sensible defaultsbuild() performs unified validation, throws MasonryError on failureFiles: src/core/layout/grid-layout.ts, src/core/layout/masonry-layout.ts
Unified interface LayoutStrategy:
interface LayoutStrategy {
calculate: (input: LayoutInput) => LayoutResult
}
Worker selects strategy based on configuration:
// src/core/worker/offscreen-canvas.ts:191
this.#layoutStrategy = mode === 'masonry' ? new MasonryLayout() : new GridLayout()
Extending with new layouts: Simply implement LayoutStrategy interface and register it in the Worker.
The project uses multiple observer/event mechanisms:
| Observer | Purpose | File |
|---|---|---|
ResizeObserver |
Monitor canvas container size changes | src/core/masonry.ts:82 |
matchMedia |
Monitor DPR changes (browser zoom) | src/core/masonry.ts:296-306 |
Worker.onmessage |
Receive Worker messages | src/core/masonry.ts:161 |
globalThis.onmessage |
Worker receives main thread messages | src/core/worker/offscreen-canvas.ts:105 |
AbortController |
Unified event listener cleanup | src/core/masonry.ts:100 |
Files: src/core/masonry.ts:94, src/core/worker/offscreen-canvas.ts:83
Async tasks (loadMore, renderLoading) may arrive concurrently; queuing ensures ordered execution:
// src/core/masonry.ts
#queue = new Queue<(() => void) | (() => Promise<void>)>()
async #runTask() {
if (!this.#isRunning) {
try {
this.#isRunning = true
while (this.#queue.size > 0) {
const task = this.#queue.dequeue()
await task?.()
}
} finally {
// try/finally ensures exception safety: even if a task throws,
// #isRunning is reset to false, preventing permanent queue deadlock
this.#isRunning = false
}
}
}
Design Notes:
try/finally exception safety: If await task?.() throws (e.g., loadMore network timeout), without finally #isRunning would remain true forever, and all subsequently enqueued tasks would never execute — the queue becomes permanently "stuck". finally ensures the lock is always released regardless of success or failure.if (!this.#isRunning) ensures only one consumption loop runs at any time. Multiple calls to #runTask() won't start multiple concurrent consumers — newly enqueued tasks are automatically consumed by the currently running while loop.while (this.#queue.size > 0) dequeues one at a time, combined with await ensures each async task completes before the next one starts.| Image Element | ImageBitmap |
|---|---|
| Bound to main thread DOM | Pure data object, no DOM dependency |
| Cannot transfer cross-thread | Supports Transferable zero-copy transfer |
| Each drawImage requires decoding | Pre-decoded, better drawing performance |
// src/core/image-loader.ts:80
return await createImageBitmap(result) // blob → pre-decoded bitmap
Configured in vite.config.ts as worker.format: 'iife':
#startAnimationLoop)// src/core/worker/offscreen-canvas.ts
#animationRunning = false
#startAnimationLoop() {
if (this.#animationRunning) {
return // Prevent duplicate start: only one rAF loop runs at a time
}
this.#animationRunning = true
const renderFrame = () => {
// Step 1: Process inertia scrolling (decay velocity each frame + re-render)
if (this.#isInertiaActive) {
this.#tickInertia()
this.#handleRerender()
}
// Step 2: Check if there are still items in loading state
const loadingItems = this.#gridItems.filter((item) => item.status !== 'loaded')
const ids = loadingItems.map((item) => item.id)
if (ids.length > 0) {
// idsChanged optimization: only send message when loading items set actually changes
// Avoids sending duplicate RenderLoading requests every frame
const idsChanged =
ids.length !== this.#lastLoadingIds.size ||
ids.some((id) => !this.#lastLoadingIds.has(id))
if (idsChanged) {
this.#lastLoadingIds = new Set(ids)
this.#sendMessage(MessageType.RenderLoading, ids)
}
} else {
this.#lastLoadingIds.clear()
}
// Step 3: Conditional exit — stop loop when no inertia and no loading items
const hasWork = this.#isInertiaActive || ids.length > 0
if (hasWork) {
requestAnimationFrame(renderFrame)
} else {
this.#animationRunning = false
}
}
renderFrame() // Execute first frame immediately, don't wait for next vsync
}
Design Notes:
idsChanged optimization: Placeholder rendering is a cross-thread async flow (Worker → Main → render → Main → Worker). Sending RenderLoading every frame would flood the message channel with redundant requests. idsChanged uses Set comparison to ensure messages are only sent when the loading set actually changes.hasWork is false, sets #animationRunning = false and stops the loop. This means once all images are loaded and no inertia scrolling is active, the rAF loop completely stops — zero CPU consumption. Subsequent scrolls or new data arrival call #startAnimationLoop() again to restart.renderFrame() called immediately: Not requestAnimationFrame(renderFrame) but direct renderFrame(). This ensures the first frame executes immediately without waiting for the next vsync signal (~16.7ms delay).Worker uses two canvases: #canvas (main) + #backgroundCanvas (background cache)
// src/core/worker/offscreen-canvas.ts:66-67
#backgroundCanvas!: OffscreenCanvas
#canvas!: OffscreenCanvas
Reason: Background (gradient) doesn't change per frame; separation avoids recalculating gradient stops every frame → just drawImage copy from cache.
#handleRerender)// src/core/worker/offscreen-canvas.ts
#handleRerender() {
if (this.#context) {
try {
// Step 1: Clear main canvas current frame content
this.#clear()
// Step 2: Clear background canvas (prepare for redraw)
this.#clearBackground()
// Step 3: Draw gradient/solid background on background canvas
this.#handleRenderBackground()
// Step 4: Copy background canvas content to main canvas (drawImage bulk copy)
this.#copyBackground()
// Step 5: Save current transform matrix state
this.#context.save()
// Step 6: Apply scroll offset (translate instead of per-item coordinate calculation)
this.#context.translate(-this.#scrollX, -this.#scrollY)
// Step 7: Choose rendering strategy based on mode
if (this.#isLoopActive) {
this.#renderLoopedItems(this.#gridItems) // Seamless loop: modulo mapping
} else {
this.#renderGridItems(this.#getVisibleItems(this.#gridItems)) // Normal mode: viewport culling
}
// Step 8: Restore transform matrix
this.#context.restore()
} catch (error) {
this.#sendError(error)
}
}
}
Design Notes:
save()/restore() pairing: translate shifts the coordinate origin; restore() ensures the next frame's background drawing isn't affected by scroll offset. Forgetting restore() would cause the background to move with scrolling.translate(-scrollX, -scrollY) replaces per-item offset: Canvas provides matrix transform APIs; a single translate is more efficient than N items each subtracting scrollX/scrollY — only one GPU state change.createLinearGradient + addColorStop calls are expensive at large pixel counts. Separated to #backgroundCanvas, gradients are only regenerated on resize/DPR changes; each frame just needs one drawImage bulk copy (GPU-optimized path).#sendError forwards exceptions back to main thread's onError callback, preventing render errors from crashing the Worker.index.ts
└── core/builder.ts
└── core/masonry.ts (main orchestrator)
├── core/image-loader.ts
├── core/placeholder/*
├── helper/validator.ts + core/rules.ts
└── core/worker/offscreen-canvas.ts (Worker entry)
├── core/layout/grid-layout.ts
├── core/layout/masonry-layout.ts
├── core/worker/protocol.ts
└── helper/background.ts
| Strategy | Effect |
|---|---|
| Worker offscreen rendering | Zero rendering blocking on main thread |
| ImageBitmap Transferable | Zero-copy image transfer |
| Viewport culling | Only render dozens of 10,000+ elements |
| Background layer cache | Avoid recalculating gradient per frame |
| Inertia stop threshold | Stop animation loop when velocity < 0.5px |
| Conditional rAF | No idle spinning when no work |
| debounce resize | Prevent high-frequency resize messages |
| p-limit concurrency | Avoid too many simultaneous network requests |