Skip to content

Commit 845cb8b

Browse files
DavidBabinecclaude
andauthored
fix(editor): tolerate malformed settings.fonts in Framework and Typography panels (CoreBunch#180)
FrameworkHome crashed the whole editor with "fonts.items is not iterable" when settings.fonts existed but items was missing or not an array (seen on a live install after a collab-branch shell projection wrote a partial fonts object). The selector spread settings.fonts.items unguarded, and the same unguarded shape assumption existed in FontsSection and resolveFontTokenStack. - Add useInstalledFontFaces hook: single tolerant reader of settings.fonts that normalizes missing/partial shapes to [] and dedupes installed font faces; FrameworkHome and FontsSection now share it instead of each hand-rolling the spread. - Guard resolveFontTokenStack in @core/fonts against a missing items array so published-CSS generation can't crash either. - Regression test renders FrameworkHome against the malformed shapes (fonts without items, fonts undefined) — fails pre-fix with the exact production error, passes post-fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 348ea1a commit 845cb8b

5 files changed

Lines changed: 130 additions & 56 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* FrameworkHome must degrade gracefully — not crash the editor body — when
3+
* `site.settings.fonts` is malformed (present, but without an `items` array).
4+
*
5+
* Every load path (client adapter AND server repository) runs the shell
6+
* through `parseSiteFontsSettings`, so validated sites can never carry this
7+
* shape. But the editor store is also written by paths outside those parsers
8+
* (live sync experiments, future plugins), and a field-level regression here
9+
* previously threw `TypeError: fonts is not iterable` inside
10+
* `useInstalledFontFaces`, unmounting the whole `site-editor-body` chunk.
11+
* FrameworkHome now reads fonts with the same tolerant
12+
* `s.site?.settings.fonts?.items ?? EMPTY` selector pattern as FontsSection.
13+
*/
14+
import { afterEach, describe, expect, it } from 'bun:test'
15+
import React from 'react'
16+
import { cleanup, render, screen } from '@testing-library/react'
17+
import { FrameworkHome } from '@site/panels/FrameworkPanel/FrameworkHome'
18+
import { useEditorStore } from '@site/store/store'
19+
import type { SiteFontsSettings } from '@core/fonts'
20+
import { makeSite } from '../fixtures'
21+
22+
afterEach(cleanup)
23+
24+
function renderWithFonts(fonts: SiteFontsSettings | undefined): void {
25+
const site = makeSite()
26+
site.settings.fonts = fonts
27+
useEditorStore.setState({ site, activePageId: site.pages[0]!.id })
28+
render(<FrameworkHome />)
29+
}
30+
31+
describe('FrameworkHome with malformed settings.fonts', () => {
32+
it('renders the overview instead of throwing when fonts has no items array', () => {
33+
// Deliberately malformed — simulates an unvalidated writer (e.g. a raw
34+
// projection) putting a fonts object without `items` into the store.
35+
renderWithFonts({} as SiteFontsSettings)
36+
expect(screen.getByText('Typography')).toBeTruthy()
37+
// Specimen falls back to the role-only labels — no token, no family.
38+
expect(screen.getByText('Heading')).toBeTruthy()
39+
expect(screen.getByText('Body')).toBeTruthy()
40+
})
41+
42+
it('renders when fonts carries tokens but no items (partial object)', () => {
43+
renderWithFonts({
44+
tokens: [
45+
{
46+
id: 'token-1',
47+
name: 'Primary',
48+
variable: 'font-primary',
49+
fallback: 'sans-serif',
50+
order: 0,
51+
createdAt: 1,
52+
updatedAt: 1,
53+
},
54+
],
55+
} as SiteFontsSettings)
56+
// The token still labels the specimen; the missing items array must not
57+
// throw in useInstalledFontFaces or resolveFontTokenStack.
58+
expect(screen.getByText('Heading · --font-primary')).toBeTruthy()
59+
})
60+
61+
it('still labels the specimen from a valid installed family', () => {
62+
renderWithFonts({
63+
items: [
64+
{
65+
id: 'font-1',
66+
source: 'custom',
67+
family: 'PP Neue Montreal',
68+
variants: ['400'],
69+
subsets: ['latin'],
70+
files: [],
71+
createdAt: 1,
72+
updatedAt: 1,
73+
},
74+
],
75+
})
76+
expect(screen.getByText('Heading · PP Neue Montreal')).toBeTruthy()
77+
})
78+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* useInstalledFontFaces — inject the site's installed `@font-face` rules into
3+
* the admin document head so panels can render text in the site's real fonts.
4+
*
5+
* The canvas iframe injects the same rules for its preview, but the admin
6+
* shell (where panels live) carries no `@font-face` declarations of its own —
7+
* without this, any `fontFamily` referencing an installed family silently
8+
* falls back to system-ui. The self-hosted `/uploads/fonts/...` `src` URLs
9+
* resolve through the dev proxy / published server exactly as on the canvas.
10+
*
11+
* Shared by the Typography panel's FontsSection and the Framework panel's
12+
* FrameworkHome — `dataSource` tags each caller's `<style>` element so the
13+
* two injections stay distinguishable in the inspector.
14+
*/
15+
import { useEffect } from 'react'
16+
import type { FontEntry } from '@core/fonts'
17+
import { generateSiteFontsCss } from '@core/fonts'
18+
19+
export function useInstalledFontFaces(fonts: readonly FontEntry[], dataSource: string): void {
20+
const css = generateSiteFontsCss({ items: [...fonts] })
21+
useEffect(() => {
22+
if (!css) return
23+
const styleEl = document.createElement('style')
24+
styleEl.setAttribute('data-source', dataSource)
25+
styleEl.textContent = css
26+
document.head.appendChild(styleEl)
27+
return () => {
28+
styleEl.remove()
29+
}
30+
}, [css, dataSource])
31+
}

src/admin/pages/site/panels/FrameworkPanel/FrameworkHome.tsx

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
*
1515
* A footer button opens the Manage Core Framework dialog (import / remove / prune).
1616
*/
17-
import { useEffect, type CSSProperties, type ReactNode } from 'react'
17+
import { type CSSProperties, type ReactNode } from 'react'
1818
import { useEditorStore } from '@site/store/store'
19+
import { useInstalledFontFaces } from '@site/hooks/useInstalledFontFaces'
1920
import { Button } from '@ui/components/Button'
2021
import { Tooltip } from '@ui/components/Tooltip'
2122
import { ColorsSwatchSolidIcon } from 'pixel-art-icons/icons/colors-swatch-solid'
@@ -25,10 +26,9 @@ import { SlidersHorizontalIcon } from 'pixel-art-icons/icons/sliders-horizontal'
2526
import { ArrowRightIcon } from 'pixel-art-icons/icons/arrow-right'
2627
import type { PixelArtIconComponent } from '@core/dashboard'
2728
import type { FrameworkColorToken, FrameworkSpacingGroup } from '@core/framework-schema'
28-
import type { FontEntry, SiteFontsSettings } from '@core/fonts'
29+
import type { FontEntry, FontToken } from '@core/fonts'
2930
import {
3031
fontFamilyStackForEntry,
31-
generateSiteFontsCss,
3232
resolveFontTokenStack,
3333
sortFontTokens,
3434
} from '@core/fonts'
@@ -49,27 +49,8 @@ const SPECIMEN_BODY =
4949
// changes the snapshot identity every render and loops forever.
5050
const EMPTY_TOKENS: readonly FrameworkColorToken[] = []
5151
const EMPTY_SPACING: readonly FrameworkSpacingGroup[] = []
52-
const EMPTY_FONTS_SETTINGS: SiteFontsSettings = { items: [], tokens: [] }
53-
54-
/**
55-
* Inject the site's installed `@font-face` rules into the admin document head
56-
* so the specimen renders in the real font. The admin shell carries no
57-
* `@font-face` of its own. Mirrors `useInstalledFontFaces` in the Typography
58-
* panel.
59-
*/
60-
function useInstalledFontFaces(fonts: readonly FontEntry[]): void {
61-
const css = generateSiteFontsCss({ items: [...fonts] })
62-
useEffect(() => {
63-
if (!css) return
64-
const styleEl = document.createElement('style')
65-
styleEl.setAttribute('data-source', 'instatic-framework-home-fonts')
66-
styleEl.textContent = css
67-
document.head.appendChild(styleEl)
68-
return () => {
69-
styleEl.remove()
70-
}
71-
}, [css])
72-
}
52+
const EMPTY_FONT_ITEMS: readonly FontEntry[] = []
53+
const EMPTY_FONT_TOKENS: readonly FontToken[] = []
7354

7455
export function FrameworkHome() {
7556
const colorTokens = useEditorStore(
@@ -81,12 +62,17 @@ export function FrameworkHome() {
8162
const frameworkPreferences = useEditorStore(
8263
(s) => s.site?.settings.framework?.preferences ?? null,
8364
)
84-
const fontsSettings = useEditorStore((s) => s.site?.settings.fonts ?? EMPTY_FONTS_SETTINGS)
65+
// Fonts are read with the same tolerant `?.items ?? EMPTY` selector pattern
66+
// as FontsSection: the store shape is guaranteed by validation on every load
67+
// path, but a malformed `settings.fonts` written by an unvalidated writer
68+
// must degrade to the empty library here — not crash the whole editor body.
69+
const fontsSettings = useEditorStore((s) => s.site?.settings.fonts ?? null)
70+
const fontItems = useEditorStore((s) => s.site?.settings.fonts?.items ?? EMPTY_FONT_ITEMS)
71+
const rawFontTokens = useEditorStore((s) => s.site?.settings.fonts?.tokens ?? EMPTY_FONT_TOKENS)
8572
const setTab = useEditorStore((s) => s.setFrameworkPanelTab)
8673
const setManagerOpen = useEditorStore((s) => s.setFrameworkManagerOpen)
8774

88-
const fontItems = fontsSettings.items
89-
useInstalledFontFaces(fontItems)
75+
useInstalledFontFaces(fontItems, 'instatic-framework-home-fonts')
9076

9177
const swatches = colorTokens.slice(0, MAX_SWATCHES)
9278

@@ -97,7 +83,7 @@ export function FrameworkHome() {
9783
// With no tokens we fall back to the first two installed families (labelled by
9884
// family name); with neither, both lines use the CSS system stack — the same
9985
// default a fresh text module renders in.
100-
const fontTokens = sortFontTokens(fontsSettings.tokens ?? [])
86+
const fontTokens = sortFontTokens(rawFontTokens)
10187
const headingToken = fontTokens[0]
10288
const bodyToken = fontTokens[1] ?? fontTokens[0]
10389
const bodyEntry = fontItems[1] ?? fontItems[0]

src/admin/pages/site/panels/TypographyPanel/FontsSection/FontsSection.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@
1111
* see `TypographyPanel.tsx` for the wiring.
1212
*/
1313

14-
import { useEffect, useState } from 'react'
14+
import { useState } from 'react'
1515
import type { CSSProperties } from 'react'
1616
import { Button } from '@ui/components/Button'
1717
import { SplitButton, type SplitButtonMenuItem } from '@ui/components/SplitButton'
1818
import { EmptyState } from '@ui/components/EmptyState'
1919
import { pushToast } from '@ui/components/Toast'
2020
import { useEditorStore } from '@site/store/store'
21+
import { useInstalledFontFaces } from '@site/hooks/useInstalledFontFaces'
2122
import type { FontEntry, FontToken } from '@core/fonts'
2223
import { compareVariants } from '@core/fonts'
23-
import { generateSiteFontsCss } from '@core/fonts'
2424
import {
2525
defaultFontTokenFallback,
2626
resolveFontTokenStack,
@@ -40,30 +40,6 @@ import { getErrorMessage } from '@core/utils/errorMessage'
4040
const EMPTY_FONTS: FontEntry[] = []
4141
const EMPTY_TOKENS: FontToken[] = []
4242

43-
/**
44-
* Inject the site's installed `@font-face` rules into the admin document head
45-
* so the Typography panel can render each family name in its own font.
46-
*
47-
* The canvas iframe already injects the same rules for its preview, but the
48-
* admin shell (where this panel lives) has no `@font-face` declarations of its
49-
* own — without this the `fontFamily` set on each row would silently fall back
50-
* to system-ui. The self-hosted `/uploads/fonts/...` `src` URLs resolve through
51-
* the dev proxy / published server exactly as they do on the canvas.
52-
*/
53-
function useInstalledFontFaces(fonts: FontEntry[]) {
54-
const css = generateSiteFontsCss({ items: fonts })
55-
useEffect(() => {
56-
if (!css) return
57-
const styleEl = document.createElement('style')
58-
styleEl.setAttribute('data-source', 'instatic-admin-installed-fonts')
59-
styleEl.textContent = css
60-
document.head.appendChild(styleEl)
61-
return () => {
62-
styleEl.remove()
63-
}
64-
}, [css])
65-
}
66-
6743
export function FontsSection() {
6844
const fonts = useEditorStore((s) => s.site?.settings.fonts?.items ?? EMPTY_FONTS)
6945
const fontTokens = useEditorStore((s) => s.site?.settings.fonts?.tokens ?? EMPTY_TOKENS)
@@ -79,7 +55,7 @@ export function FontsSection() {
7955
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
8056
const [editToken, setEditToken] = useState<FontToken | null>(null)
8157

82-
useInstalledFontFaces(fonts)
58+
useInstalledFontFaces(fonts, 'instatic-admin-installed-fonts')
8359

8460
const installedFamiliesLower = new Set(fonts.map((f) => f.family.toLowerCase()))
8561

src/core/fonts/tokens.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,11 @@ export function resolveFontTokenStack(
6868
fonts: SiteFontsSettings | null | undefined,
6969
): string {
7070
const fallback = sanitizeFontFallbackStack(token.fallback)
71+
// `items?.` mirrors generateSiteFontsCss's guard: a corrupted site document
72+
// (fonts object without an items array) degrades to the fallback stack
73+
// instead of throwing at render time.
7174
const entry = token.familyId
72-
? fonts?.items.find((item) => item.id === token.familyId)
75+
? fonts?.items?.find((item) => item.id === token.familyId)
7376
: undefined
7477
if (!entry) return fallback
7578
return `"${escapeCssString(entry.family)}", ${fallback}`

0 commit comments

Comments
 (0)