Skip to content
Draft
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
3 changes: 2 additions & 1 deletion src/__tests__/panels/selectorsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SelectorsPanel } from '@site/panels/SelectorsPanel'
import { PropertiesPanel } from '@site/panels/PropertiesPanel/PropertiesPanel'
import {
buildClassTokenUsageMap,
buildSelectorUsageMap,
createSelectorUsageMapSelector,
formatSelectorUsage,
getReusableClasses,
getSelectorStyleSummary,
Expand All @@ -19,6 +19,7 @@ import { makeNode, makePage, makeSite } from '../fixtures'
import '@modules/base/index'

const SRC_ROOT = join(import.meta.dir, '../../')
const buildSelectorUsageMap = createSelectorUsageMapSelector()

afterEach(cleanup)

Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/store/classUsageSnapshots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'bun:test'
import { createUsedStyleRuleIdSelector, collectUsedStyleRuleIds } from '@core/publisher'
import { createSelectorUsageMapSelector } from '@site/panels/selectorUsage'
import { makeNode, makePage, makeSite, makeVC } from '../fixtures'

describe('class usage across immutable collaboration snapshots', () => {
it('does not rescan unchanged trees when another row is projected', () => {
let reads = 0
const root = makeNode({ id: 'root', classIds: ['shared'] })
const nodes = Object.defineProperty({}, 'root', { enumerable: true, get: () => { reads++; return root } })
const page = makePage({ nodes, rootNodeId: 'root' })
const other = makePage({ nodes: { second: makeNode({ id: 'second', classIds: ['before'] }) }, rootNodeId: 'second' })
const site = makeSite({ pages: [page, other] })
const selectIds = createUsedStyleRuleIdSelector()
const selectCounts = createSelectorUsageMapSelector()
expect(selectIds(site)).toBe('before\0shared')
expect(selectCounts(site).get('shared')).toBe(1)
const baseline = reads
const changed = { ...site, pages: [page, { ...other, nodes: { second: makeNode({ id: 'second', classIds: ['after'] }) } }] }
expect(selectIds(changed)).toBe('after\0shared')
expect(selectCounts(changed).has('before')).toBe(false)
expect(reads).toBe(baseline)
expect(new Set(selectIds(changed).split('\0'))).toEqual(collectUsedStyleRuleIds(changed))
})

it('keeps counts referentially stable for text edits and handles removal and reload', () => {
const root = makeNode({ id: 'root', classIds: ['shared', 'shared'] })
const page = makePage({ nodes: { root }, rootNodeId: 'root' })
const site = makeSite({ pages: [page] })
const selectCounts = createSelectorUsageMapSelector()
const initial = selectCounts(site)
expect(initial.get('shared')).toBe(2)
expect(selectCounts({ ...site, pages: [{ ...page, nodes: { root: { ...root, props: { text: 'changed' } } } }] })).toBe(initial)
expect(selectCounts({ ...site, pages: [] }).size).toBe(0)
expect(selectCounts(site).get('shared')).toBe(2)
expect(selectCounts(null).size).toBe(0)
})

it('retains component and script references and invalidates replaced snapshots', () => {
const node = makeNode({ id: 'root', classIds: ['component-node'] })
const component = makeVC({ id: 'component', name: 'Component', classIds: ['component-shell'], tree: { rootNodeId: 'root', nodes: { root: node } } })
const site = makeSite({
visualComponents: [component],
files: [{ id: 'runtime', path: 'runtime.js', type: 'script', content: "document.body.classList.add('open')", createdAt: 1, updatedAt: 1 }],
styleRules: { scripted: { id: 'scripted', name: 'open', selector: '.open', kind: 'class', order: 0, styles: {}, contextStyles: {}, createdAt: 1, updatedAt: 1 } },
})
const selectIds = createUsedStyleRuleIdSelector()
expect(selectIds(site)).toBe([...collectUsedStyleRuleIds(site)].sort().join('\0'))
const changed = { ...site, visualComponents: [{ ...component, classIds: ['changed-shell'], tree: { ...component.tree, nodes: { root: { ...node, classIds: ['changed-node'] } } } }] }
expect(selectIds(changed)).toBe('changed-node\0changed-shell\0scripted')
expect(selectIds({ ...changed, files: [] })).toBe('changed-node\0changed-shell')
expect(selectIds(site)).toBe('component-node\0component-shell\0scripted')
})
})
3 changes: 2 additions & 1 deletion src/admin/pages/site/canvas/ClassStyleInjector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
collectBackgroundImagePaths,
collectSiteStyleBackgroundImagePaths,
treeShakeStyleRulesBySignature,
usedStyleRuleIdSignature,
createUsedStyleRuleIdSelector,
} from '@core/publisher'
import { useResponsiveEditorMediaAssets } from '@admin/pages/media/hooks/useResponsiveBackgroundStyle'
import { selectorStatePseudo } from '@site/cssStatePseudo'
Expand Down Expand Up @@ -98,6 +98,7 @@ const EMPTY_CONDITIONS: ConditionDef[] = []
* run, defeating the generator's input-identity memo across frames.
*/
const EMPTY_STYLE_RULES: Record<string, StyleRule> = {}
const usedStyleRuleIdSignature = createUsedStyleRuleIdSelector()

export function ClassStyleInjector({ targetDocument, viewport }: ClassStyleInjectorProps = {}) {
// Subscribe to class registry — shallow equality so we only re-run when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useEditorStore, selectSelectedNode } from '@site/store/store'
import { registry } from '@core/module-engine'
import { getAncestors, resolveProps } from '@core/page-tree'
import { loopSourceRegistry } from '@core/loops/registry'
import { buildClassTokenUsageMap, buildSelectorUsageMap, resolveSelectorUsage } from '../selectorUsage'
import { buildClassTokenUsageMap, createSelectorUsageMapSelector, resolveSelectorUsage } from '../selectorUsage'
import type {
AnyModuleDefinition,
} from '@core/module-engine'
Expand All @@ -34,6 +34,7 @@ import type { ActiveDocument, PanelState, FocusedPanel, PanelMode } from '../../

const DEFAULT_WIDTH = 360
const MIN_WIDTH = 280
const selectorUsageForSnapshot = createSelectorUsageMapSelector()

/**
* Everything PropertiesPanel needs to render. Field order intentionally
Expand Down Expand Up @@ -154,7 +155,7 @@ export function usePropertiesPanelData(): PropertiesPanelData {
: null
// Ambient rules report "Unused" only when provably dead; class rules report
// an exact reference count. `null` means "no badge" (unassessable ambient).
const selectorUsageById = buildSelectorUsageMap(site)
const selectorUsageById = selectorUsageForSnapshot(site)
const selectedSelectorUsage = selectedSelectorClass
? resolveSelectorUsage(
selectedSelectorClass,
Expand Down
8 changes: 4 additions & 4 deletions src/admin/pages/site/panels/SelectorsPanel/SelectorsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { DeleteSelectorDialog, SelectorNameDialog } from '../SelectorDialogs'
import { SelectorContextMenu } from './SelectorContextMenu'
import {
buildClassTokenUsageMap,
buildSelectorUsageMap,
createSelectorUsageMapSelector,
getReusableClasses,
getSelectorStyleSummary,
normalizeSelectorQuery,
Expand All @@ -45,6 +45,7 @@ type SelectorFilter = 'all' | 'user' | 'utility' | 'used' | 'unused'
* a sentinel into view.
*/
const SELECTOR_PAGE_SIZE = 100
const selectorUsageForSnapshot = createSelectorUsageMapSelector()

/** Placeholder rows shown on the first paint after the panel opens. */
const SKELETON_ROW_COUNT = 10
Expand Down Expand Up @@ -127,9 +128,8 @@ export function SelectorsPanel({
const sentinelRef = useRef<HTMLDivElement | null>(null)

const reusableClasses = getReusableClasses(site?.styleRules ?? {})
// One pass over the whole tree, memoized against `site` by the React Compiler.
// Replaces a per-row scan that scaled with selector count × node count.
const usageMap = buildSelectorUsageMap(site)
// Collaborative row projections retain the other trees, so reuse their counts.
const usageMap = selectorUsageForSnapshot(site)
// Class-token → applied-count rollup, so ambient rows can report "Unused"
// only when provably dead (anchored on a class nothing uses) instead of the
// blanket "Unused" the per-id tally produced for every ambient rule.
Expand Down
51 changes: 30 additions & 21 deletions src/admin/pages/site/panels/selectorUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,39 @@ export function getReusableClasses(classes: Record<string, StyleRule>): StyleRul
}

/**
* Tally how many nodes reference each class, in a SINGLE pass over the whole
* site tree. Returns a `Map<classId, count>`; classes with zero references are
* simply absent (callers default to 0).
*
* This replaces a per-selector scan: counting one selector at a time was
* O(selectors × pages × nodes), which made the Selectors panel janky to open
* with hundreds of generated utility classes. One pass is O(pages × nodes)
* regardless of how many selectors exist, and the React Compiler memoizes the
* result against `site` so it only recomputes when the tree changes.
* Tally page-node references across immutable store snapshots. Reuse counts
* for unchanged trees, and retain the result identity when text or props
* change without changing class assignments. Absent classes have zero usage.
*/
export function buildSelectorUsageMap(site: SiteDocument | null): Map<string, number> {
const usage = new Map<string, number>()
if (!site) return usage

for (const page of site.pages) {
for (const node of Object.values(page.nodes)) {
const classIds = node.classIds
if (!classIds) continue
for (const classId of classIds) {
usage.set(classId, (usage.get(classId) ?? 0) + 1)
export function createSelectorUsageMapSelector(): (site: SiteDocument | null) => Map<string, number> {
const trees = new WeakMap<object, Map<string, number>>()
let previousPages: SiteDocument['pages'] | null = null
let previousUsage = new Map<string, number>()
return (site) => {
if (!site) {
previousPages = null
previousUsage = new Map()
return previousUsage
}
if (site.pages === previousPages) return previousUsage
const usage = new Map<string, number>()
for (const page of site.pages) {
let counts = trees.get(page.nodes)
if (!counts) {
counts = new Map()
for (const node of Object.values(page.nodes)) {
for (const id of node.classIds ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
}
trees.set(page.nodes, counts)
}
for (const [id, count] of counts) usage.set(id, (usage.get(id) ?? 0) + count)
}
previousPages = site.pages
if (usage.size !== previousUsage.size || [...usage].some(([id, count]) => previousUsage.get(id) !== count)) {
previousUsage = usage
}
return previousUsage
}
return usage
}

export function formatSelectorUsage(count: number): string {
Expand All @@ -39,7 +48,7 @@ export function formatSelectorUsage(count: number): string {

/**
* Map each class-kind rule's selector token (`.<escaped-name>`) to how many
* nodes carry it, reusing the per-id tally from {@link buildSelectorUsageMap}.
* nodes carry it, reusing the per-id tally from {@link createSelectorUsageMapSelector}.
* `rule.selector` is already the escaped `.name` form the publisher emits, so
* tokens here compare directly against tokens pulled out of an ambient
* selector string — no re-escaping, no guesswork.
Expand Down
2 changes: 1 addition & 1 deletion src/core/publisher/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export {
collectUsedStyleRuleIds,
treeShakeStyleRules,
treeShakeStyleRulesBySignature,
usedStyleRuleIdSignature,
createUsedStyleRuleIdSelector,
} from './styleRuleTreeShake'

export { buildSiteFrameworkCss, generateFrameworkCss } from './frameworkCss'
Expand Down
49 changes: 43 additions & 6 deletions src/core/publisher/styleRuleTreeShake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ let lastScriptRuns: Set<string> = new Set()
* live site with nothing to point at.
*/
function scriptIdentifierRuns(files: SiteDocument['files']): Set<string> {
// `usedStyleRuleIdSignature` runs inside a canvas store selector, so this is
// Canvas class usage runs inside a 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
Expand Down Expand Up @@ -68,24 +68,61 @@ export function collectUsedStyleRuleIds(
}
}

addScriptStyleRuleIds(usedIds, site)
return usedIds
}

function addScriptStyleRuleIds(
usedIds: Set<string>,
site: Pick<SiteDocument, 'files' | 'styleRules'>,
): void {
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
}

/**
* A stable primitive signature suitable for store subscriptions. It changes
* only when the set of assigned class ids changes, not for unrelated edits.
* Inputs must be immutable snapshots; direct publisher callers use the
* uncached collector so in-place construction is never mistaken for a hit.
*/
export function usedStyleRuleIdSignature(
export function createUsedStyleRuleIdSelector(): (
site: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'>,
): string {
return [...collectUsedStyleRuleIds(site)].sort().join('\0')
) => string {
// Store snapshots preserve unchanged tree maps. Cache per tree, rather than
// per site: projecting one collaborative row must not rescan every other row.
const trees = new WeakMap<object, Set<string>>()
let previousSite: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'> | null = null
let signature = ''
function collect(nodes: SiteDocument['pages'][number]['nodes']): Set<string> {
const cached = trees.get(nodes)
if (cached) return cached
const ids = new Set<string>()
for (const node of Object.values(nodes)) {
for (const id of node.classIds ?? []) ids.add(id)
}
trees.set(nodes, ids)
return ids
}
return (site) => {
if (site === previousSite) return signature
const ids = new Set<string>()
for (const page of site.pages) {
for (const id of collect(page.nodes)) ids.add(id)
}
for (const component of site.visualComponents ?? []) {
for (const id of component.classIds ?? []) ids.add(id)
for (const id of collect(component.tree.nodes)) ids.add(id)
}
addScriptStyleRuleIds(ids, site)
signature = [...ids].sort().join('\0')
previousSite = site
return signature
}
}

function selectorPartCanMatch(
Expand Down