Skip to content
Open
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
49 changes: 49 additions & 0 deletions src/__tests__/publisher/classStyleInjector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ describe('generateClassCSS', () => {
function makeSite(
styleRules: SiteDocument['styleRules'],
nodeClassIds: Record<string, string[]> = {},
files: SiteDocument['files'] = [],
): SiteDocument {
const node: PageNode = {
id: 'root',
Expand Down Expand Up @@ -722,12 +723,60 @@ function makeSite(
shortcuts: {},
},
styleRules,
files,
createdAt: 0,
updatedAt: 0,
}
}

function makeScript(path: string, content: string): SiteDocument['files'][number] {
return { id: path, path, type: 'script', content } as SiteDocument['files'][number]
}

describe('collectClassCSS', () => {
it('keeps a class no node carries when a runtime script names it', () => {
// The reason tree-shaking cannot work from node class ids alone: a modifier
// toggled by script is never authored onto a node, so publish saw it as
// dead and dropped it. The rule read back correctly everywhere up to
// publish, so the failure only showed on the live site.
const site = makeSite(
{
nav: makeClass('nav', { display: 'none' }),
'nav-open': makeClass('nav-open', { display: 'flex' }, {}, 'nav--open'),
},
{ root: ['nav'] },
[makeScript('scripts/nav.js', "button.addEventListener('click', () => menu.classList.toggle('nav--open'))")],
)
const css = collectClassCSS(site)
expect(css).toContain('.nav {')
expect(css).toContain('.nav--open {')
expect(css).toContain('display: flex')
})

it('still drops a class that neither a node nor a script references', () => {
const site = makeSite(
{
nav: makeClass('nav', { display: 'none' }),
'nav-open': makeClass('nav-open', { display: 'flex' }, {}, 'nav--open'),
orphan: makeClass('orphan', { color: 'red' }),
},
{ root: ['nav'] },
[makeScript('scripts/nav.js', "menu.classList.toggle('nav--open')")],
)
const css = collectClassCSS(site)
expect(css).toContain('.nav--open {')
expect(css).not.toContain('.orphan')
})

it('only counts script files, not other site file types', () => {
const site = makeSite(
{ orphan: makeClass('orphan', { color: 'red' }) },
{},
[{ id: 'notes', path: 'docs/notes.md', type: 'doc', content: 'the orphan class is for later' } as SiteDocument['files'][number]],
)
expect(collectClassCSS(site)).not.toContain('.orphan')
})

it('emits user-authored CSS but skips framework-generated CSS', () => {
const userClass = makeClass('user-class', { color: 'green' })
const frameworkClass: StyleRule = {
Expand Down
59 changes: 56 additions & 3 deletions src/core/publisher/styleRuleTreeShake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,54 @@ import {
type StyleRule,
} from '@core/page-tree'

/** Collect every registry class id referenced by page and Visual Component nodes. */
let lastScriptFiles: SiteDocument['files'] | null = null
let lastScriptRuns: Set<string> = new Set()

/**
* Identifier-shaped runs in a runtime script's source.
*
* A modifier a script toggles never appears on an authored node, so node class
* ids alone cannot see it. The script is the only place it is written down, in
* whatever call the author used — `classList.add('nav--open')`, a `className`
* assignment, a template literal, a lookup table of state names. Rather than
* model those shapes, split the source on everything a CSS class name cannot
* contain and keep the runs that survive.
*
* This over-collects: `add`, `length` and every other identifier in the file
* land in the set too, so a class named after one of them is kept even when no
* script really references it. That is the safe direction. The cost of a false
* positive is a few bytes of CSS; the cost of a false negative is a rule that
* is correct everywhere until publish drops it and the feature dies on the
* live site with nothing to point at.
*/
function scriptIdentifierRuns(files: SiteDocument['files']): Set<string> {
// `usedStyleRuleIdSignature` runs inside a canvas store selector, so this is
// hit on every store change. The store snapshot is immutable, so identity on
// the files array is enough to skip re-splitting unchanged sources.
if (files === lastScriptFiles) return lastScriptRuns

const runs = new Set<string>()
for (const file of files) {
if (file.type !== 'script' || typeof file.content !== 'string') continue
for (const run of file.content.split(/[^A-Za-z0-9_-]+/)) {
if (run) runs.add(run)
}
}

lastScriptFiles = files
lastScriptRuns = runs
return runs
}

/**
* Collect every registry class id referenced by page and Visual Component
* nodes, plus every class a runtime script names.
*
* Script-referenced ids are unioned in, never subtracted, so this can only
* ever keep more CSS than node class ids alone would.
*/
export function collectUsedStyleRuleIds(
site: Pick<SiteDocument, 'pages' | 'visualComponents'>,
site: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'>,
): Set<string> {
const usedIds = new Set<string>()
for (const page of site.pages) {
Expand All @@ -22,6 +67,14 @@ export function collectUsedStyleRuleIds(
for (const id of node.classIds ?? []) usedIds.add(id)
}
}

const runs = scriptIdentifierRuns(site.files ?? [])
if (runs.size > 0) {
for (const rule of Object.values(site.styleRules ?? {})) {
if (rule.kind === 'class' && runs.has(rule.name)) usedIds.add(rule.id)
}
}

return usedIds
}

Expand All @@ -30,7 +83,7 @@ export function collectUsedStyleRuleIds(
* only when the set of assigned class ids changes, not for unrelated edits.
*/
export function usedStyleRuleIdSignature(
site: Pick<SiteDocument, 'pages' | 'visualComponents'>,
site: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'>,
): string {
return [...collectUsedStyleRuleIds(site)].sort().join('\0')
}
Expand Down
Loading