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
refactor(admin): remove circular deps and recover lazy HMR loads
  • Loading branch information
DavidBabinec committed Jun 18, 2026
commit 7b679102944e21c4a5e9a49bb6c88c76ba3effe7
3 changes: 2 additions & 1 deletion docs/reference/architecture-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Catalog of every test in `src/__tests__/architecture/`. These are structural gat

## TL;DR

- 89 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, content storage, boundary validation, module size, AI, auth, error handling, etc.
- 90 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, content storage, boundary validation, module size, AI, auth, error handling, etc.
- Naming convention: `<topic>.test.ts` (kebab-case) or `<group>-<topic>.test.ts`. A few legacy `task<N>-*` ids remain for live invariants; new gates should use topic names.
- Run them all: `bun test src/__tests__/architecture/`.
- Most are **import / source scans** — they parse the files in scope and assert / reject patterns. Some are unit-style (a small in-test database, a synthesized page tree).
Expand Down Expand Up @@ -114,6 +114,7 @@ See [docs/reference/ui-primitives.md](ui-primitives.md).
| Test | What it enforces |
|-----------------------------------------------|----------------------------------------------------------------------------------|
| `canvasFastRefreshBoundaries.test.ts` | `.tsx` files don't mix component + non-component exports (breaks HMR). |
| `no-circular-dependencies.test.ts` | `madge` finds zero tsconfig-aware circular dependencies across `src` and `server`. |
| `canvas-aware-selectors.test.ts` | Canvas-related store selectors are subscribed correctly to canvas-state slices. |
| `admin-router-usage.test.ts` | Internal admin navigation uses `@admin/lib/routing`; raw `/admin` anchors and `react-router-dom` are banned. |
| `framework-typography-spacing.test.ts` | The site framework's typography / spacing tokens compile correctly. |
Expand Down
60 changes: 2 additions & 58 deletions server/repositories/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,71 +6,15 @@ import {
parseVariants,
type MediaAssetRow,
} from './mediaAssetMapping'
import type { MediaAsset, MediaVariant } from './mediaTypes'

// The row ↔ asset mapping unit (column constants, `MediaAssetRow`,
// `mapMediaAssetRow`, and the JSON parsers) lives in `./mediaAssetMapping` so it
// can be shared verbatim with the publisher's render-time prefetch without
// duplication. This module owns the asset domain types (`MediaAsset`,
// `MediaVariant`) and every CRUD query.

export interface MediaVariant {
width: number
height: number
format: 'webp' | 'jpeg' | 'png' | 'avif'
/**
* Public URL the renderer emits (`/uploads/<storage>` for local; an
* absolute URL like `https://cdn.example.com/...` for `'public-url'`
* adapters; `/uploads/<storage>` again for `'signed-redirect'` /
* `'proxy'` adapters because the router resolves them on request).
*/
path: string
sizeBytes: number
/**
* Adapter-internal storage handle. For local-disk this is the basename
* under `uploadsDir`; for S3 it's the bucket key. Used by `dispatchDelete`
* to remove the right bytes when the asset is purged.
*/
storagePath: string
/** Adapter id that wrote this variant; `''` for the built-in local-disk adapter. */
storageAdapterId: string
}

export interface MediaAsset {
id: string
filename: string
mimeType: string
sizeBytes: number
publicPath: string
uploadedByUserId: string | null
createdAt: string
altText: string
caption: string
title: string
tags: string[]
width: number | null
height: number | null
durationMs: number | null
dominantColor: string | null
deletedAt: string | null
replacedAt: string | null
folderIds: string[]
blurHash: string | null
variants: MediaVariant[]
posterPath: string | null
/**
* Id of the storage adapter that wrote this asset. Empty string for the
* built-in local-disk adapter (historical assets keep this default).
* Reads dispatch through THIS field, not the currently-elected adapter,
* so an election swap can't strand existing rows.
*/
storageAdapterId: string
/**
* True when the bytes live outside the host's uploads dir
* (servingMode `'public-url'`). The hard-delete path uses this to choose
* between local `rm` and `adapter.delete()`.
*/
externallyHosted: boolean
}
export type { MediaAsset, MediaVariant } from './mediaTypes'

interface CreateMediaAssetInput {
id: string
Expand Down
2 changes: 1 addition & 1 deletion server/repositories/mediaAssetMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import { isoDate, isoDateOrNull } from '@core/utils/isoDate'
import type { MediaAsset, MediaVariant } from './media'
import type { MediaAsset, MediaVariant } from './mediaTypes'

/**
* Single source of truth for the hydrated media-asset projection. Spliced into
Expand Down
50 changes: 50 additions & 0 deletions server/repositories/mediaTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Media repository domain types.
*
* Kept outside the CRUD repository and row mapper so both can share the same
* asset shape without importing each other.
*/

export interface MediaVariant {
width: number
height: number
format: 'webp' | 'jpeg' | 'png' | 'avif'
/**
* Public URL the renderer emits (`/uploads/<storage>` for local; an absolute
* URL for public external storage; local route again for redirect/proxy modes).
*/
path: string
sizeBytes: number
/** Adapter-internal storage handle. */
storagePath: string
/** Adapter id that wrote this variant; `''` for the built-in local adapter. */
storageAdapterId: string
}

export interface MediaAsset {
id: string
filename: string
mimeType: string
sizeBytes: number
publicPath: string
uploadedByUserId: string | null
createdAt: string
altText: string
caption: string
title: string
tags: string[]
width: number | null
height: number | null
durationMs: number | null
dominantColor: string | null
deletedAt: string | null
replacedAt: string | null
folderIds: string[]
blurHash: string | null
variants: MediaVariant[]
posterPath: string | null
/** Empty string for the built-in local-disk adapter. */
storageAdapterId: string
/** True when bytes live outside the host uploads dir. */
externallyHosted: boolean
}
28 changes: 27 additions & 1 deletion src/__tests__/architecture/canvasFastRefreshBoundaries.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'bun:test'

Expand All @@ -8,6 +8,17 @@ function readSource(path: string): string {
return readFileSync(join(SRC_ROOT, path), 'utf8')
}

function collectFiles(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
const stat = statSync(full)
if (stat.isDirectory()) files.push(...collectFiles(full))
else if (entry.endsWith('Editor.tsx')) files.push(full)
}
return files
}

describe('Canvas Fast Refresh boundaries', () => {
it('keeps component modules free of Fast Refresh suppression comments', () => {
const files = [
Expand All @@ -34,4 +45,19 @@ describe('Canvas Fast Refresh boundaries', () => {

expect(source).not.toContain('export function createSandboxSrcDoc')
})

it('keeps base module editors independent of registration barrels', () => {
const editorFiles = collectFiles(join(SRC_ROOT, 'modules/base'))
expect(editorFiles.length).toBeGreaterThan(0)

const offenders: string[] = []
for (const file of editorFiles) {
const source = readFileSync(file, 'utf8')
if (/from ['"]\.\/index['"]/.test(source)) {
offenders.push(file.replace(`${SRC_ROOT}/`, ''))
}
}

expect(offenders).toEqual([])
})
})
41 changes: 41 additions & 0 deletions src/__tests__/architecture/no-circular-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'bun:test'
import { join } from 'node:path'

const ROOT = join(import.meta.dir, '../../..')

function decode(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes)
}

describe('Circular dependencies', () => {
it('keeps the tsconfig-aware source graph cycle-free', () => {
const result = Bun.spawnSync({
cmd: [
process.execPath,
'x',
'madge',
'--circular',
'--ts-config',
'tsconfig.json',
'--extensions',
'ts,tsx',
'src',
'server',
],
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
})

const output = `${decode(result.stdout)}${decode(result.stderr)}`
if (result.exitCode !== 0) {
throw new Error(
`Circular dependencies found. Run the same command locally for the full graph:\n` +
`bun x madge --circular --ts-config tsconfig.json --extensions ts,tsx src server\n\n` +
output,
)
}

expect(output).toContain('No circular dependency found')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ describe('Site editor shell lazy body', () => {
const body = readAdminFile('layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx')

expect(layout).toContain("import('./AdminCanvasEditorBody')")
expect(layout).toContain('prewarmedLazy<AdminCanvasEditorBodyProps>')
expect(layout).toContain('<LazyChunkBoundary')
expect(layout).toContain('onReset={AdminCanvasEditorBody.reset}')
expect(layout).toContain('scheduleAfterFirstPaint')
expect(layout).not.toContain("@dnd-kit/core")
expect(layout).not.toContain("@admin/pages/site/canvas")
Expand Down Expand Up @@ -60,6 +63,7 @@ describe('Site editor shell lazy body', () => {
// shell must still paint canvas-shaped skeleton frames; otherwise cold Site
// loads show only the toolbar over an empty black workspace.
expect(layout).toContain('<AdminCanvasEditorBodyLoading />')
expect(layout).toContain('fallback={<AdminCanvasEditorBodyLoading />}')
expect(layout).toContain('@admin/shared/CanvasFrameSkeleton')
expect(layout).toContain('<CanvasFrameSkeletonFrame')
expect(layout).not.toContain('<span>Loading editor</span>')
Expand Down
23 changes: 19 additions & 4 deletions src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
CanvasFrameSkeletonFrame,
DEFAULT_CANVAS_FRAME_SKELETON_BREAKPOINTS,
} from '@admin/shared/CanvasFrameSkeleton'
import { LazyChunkBoundary } from '@admin/lib/LazyChunkBoundary'
import { prewarmedLazy } from '@admin/lib/prewarmedLazy'
import styles from './AdminCanvasLayout.module.css'
import { lazy, Suspense, useEffect, useState } from 'react'
import { useCurrentAdminUser } from '@admin/sessionContext'
Expand All @@ -67,8 +69,16 @@ import {
import { EditorPermissionsProvider } from '@site/EditorPermissionsProvider'
import type { EditorPermissions } from '@site/editorPermissionsContext'

const AdminCanvasEditorBody = lazy(() =>
import('./AdminCanvasEditorBody').then((m) => ({ default: m.AdminCanvasEditorBody })),
interface AdminCanvasEditorBodyProps {
canEditDraftSite: boolean
canSaveSite: boolean
loadError: string | null
}

const AdminCanvasEditorBody = prewarmedLazy<AdminCanvasEditorBodyProps>(
() =>
import('./AdminCanvasEditorBody').then((m) => ({ default: m.AdminCanvasEditorBody })),
{ displayName: 'AdminCanvasEditorBody' },
)

// SettingsModal is heavy (~37 KB raw) and closed 99% of the time. lazy()
Expand Down Expand Up @@ -216,13 +226,18 @@ export function AdminCanvasLayout() {
/>

{loadEditorBody ? (
<Suspense fallback={<AdminCanvasEditorBodyLoading />}>
<LazyChunkBoundary
location="site-editor-body"
fallback={<AdminCanvasEditorBodyLoading />}
resetKeys={[site?.id ?? null]}
onReset={AdminCanvasEditorBody.reset}
>
<AdminCanvasEditorBody
canEditDraftSite={canEditDraftSite}
canSaveSite={canSaveSite}
loadError={loadError}
/>
</Suspense>
</LazyChunkBoundary>
) : (
<AdminCanvasEditorBodyLoading />
)}
Expand Down
46 changes: 46 additions & 0 deletions src/admin/lib/LazyChunkBoundary.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.chunkFailure {
box-sizing: border-box;
display: grid;
flex: 1 1 auto;
width: 100%;
min-height: 260px;
min-width: 0;
place-items: center;
padding: 32px;
background: var(--editor-bg);
color: var(--editor-text);
}

.panel {
display: flex;
width: min(460px, 100%);
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: var(--editor-radius);
box-shadow: var(--panel-shadow);
}

.title {
margin: 0;
color: var(--editor-text-bright);
font-size: 14px;
font-weight: 650;
line-height: 1.3;
}

.message {
margin: 0;
color: var(--editor-text-secondary);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}

.actions {
display: flex;
align-items: center;
gap: 8px;
}
Loading