From 5e0df611cc88b508f5638d945487afc66b9ded46 Mon Sep 17 00:00:00 2001 From: mdantonio Date: Wed, 30 Sep 2026 16:02:17 +0200 Subject: [PATCH] fix(editor): reuse class usage across unchanged page trees --- src/__tests__/panels/selectorsPanel.test.tsx | 3 +- .../store/classUsageSnapshots.test.ts | 54 +++++++++++++++++++ .../pages/site/canvas/ClassStyleInjector.tsx | 3 +- .../PropertiesPanel/usePropertiesPanelData.ts | 5 +- .../panels/SelectorsPanel/SelectorsPanel.tsx | 8 +-- src/admin/pages/site/panels/selectorUsage.ts | 51 ++++++++++-------- src/core/publisher/index.ts | 2 +- src/core/publisher/styleRuleTreeShake.ts | 49 ++++++++++++++--- 8 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/store/classUsageSnapshots.test.ts diff --git a/src/__tests__/panels/selectorsPanel.test.tsx b/src/__tests__/panels/selectorsPanel.test.tsx index 2c37490ec..440689dc8 100644 --- a/src/__tests__/panels/selectorsPanel.test.tsx +++ b/src/__tests__/panels/selectorsPanel.test.tsx @@ -7,7 +7,7 @@ import { SelectorsPanel } from '@site/panels/SelectorsPanel' import { PropertiesPanel } from '@site/panels/PropertiesPanel/PropertiesPanel' import { buildClassTokenUsageMap, - buildSelectorUsageMap, + createSelectorUsageMapSelector, formatSelectorUsage, getReusableClasses, getSelectorStyleSummary, @@ -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) diff --git a/src/__tests__/store/classUsageSnapshots.test.ts b/src/__tests__/store/classUsageSnapshots.test.ts new file mode 100644 index 000000000..249d695a9 --- /dev/null +++ b/src/__tests__/store/classUsageSnapshots.test.ts @@ -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') + }) +}) diff --git a/src/admin/pages/site/canvas/ClassStyleInjector.tsx b/src/admin/pages/site/canvas/ClassStyleInjector.tsx index f62234b76..6f7c82039 100644 --- a/src/admin/pages/site/canvas/ClassStyleInjector.tsx +++ b/src/admin/pages/site/canvas/ClassStyleInjector.tsx @@ -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' @@ -98,6 +98,7 @@ const EMPTY_CONDITIONS: ConditionDef[] = [] * run, defeating the generator's input-identity memo across frames. */ const EMPTY_STYLE_RULES: Record = {} +const usedStyleRuleIdSignature = createUsedStyleRuleIdSelector() export function ClassStyleInjector({ targetDocument, viewport }: ClassStyleInjectorProps = {}) { // Subscribe to class registry — shallow equality so we only re-run when diff --git a/src/admin/pages/site/panels/PropertiesPanel/usePropertiesPanelData.ts b/src/admin/pages/site/panels/PropertiesPanel/usePropertiesPanelData.ts index add25da07..26b8a058f 100644 --- a/src/admin/pages/site/panels/PropertiesPanel/usePropertiesPanelData.ts +++ b/src/admin/pages/site/panels/PropertiesPanel/usePropertiesPanelData.ts @@ -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' @@ -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 @@ -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, diff --git a/src/admin/pages/site/panels/SelectorsPanel/SelectorsPanel.tsx b/src/admin/pages/site/panels/SelectorsPanel/SelectorsPanel.tsx index 2da99cbd2..098a89515 100644 --- a/src/admin/pages/site/panels/SelectorsPanel/SelectorsPanel.tsx +++ b/src/admin/pages/site/panels/SelectorsPanel/SelectorsPanel.tsx @@ -24,7 +24,7 @@ import { DeleteSelectorDialog, SelectorNameDialog } from '../SelectorDialogs' import { SelectorContextMenu } from './SelectorContextMenu' import { buildClassTokenUsageMap, - buildSelectorUsageMap, + createSelectorUsageMapSelector, getReusableClasses, getSelectorStyleSummary, normalizeSelectorQuery, @@ -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 @@ -127,9 +128,8 @@ export function SelectorsPanel({ const sentinelRef = useRef(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. diff --git a/src/admin/pages/site/panels/selectorUsage.ts b/src/admin/pages/site/panels/selectorUsage.ts index 6107c2bbb..d2cd72240 100644 --- a/src/admin/pages/site/panels/selectorUsage.ts +++ b/src/admin/pages/site/panels/selectorUsage.ts @@ -6,30 +6,39 @@ export function getReusableClasses(classes: Record): StyleRul } /** - * Tally how many nodes reference each class, in a SINGLE pass over the whole - * site tree. Returns a `Map`; 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 { - const usage = new Map() - 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 { + const trees = new WeakMap>() + let previousPages: SiteDocument['pages'] | null = null + let previousUsage = new Map() + return (site) => { + if (!site) { + previousPages = null + previousUsage = new Map() + return previousUsage + } + if (site.pages === previousPages) return previousUsage + const usage = new Map() + 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 { @@ -39,7 +48,7 @@ export function formatSelectorUsage(count: number): string { /** * Map each class-kind rule's selector token (`.`) 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. diff --git a/src/core/publisher/index.ts b/src/core/publisher/index.ts index 1f1cec618..9bbc42d24 100644 --- a/src/core/publisher/index.ts +++ b/src/core/publisher/index.ts @@ -64,7 +64,7 @@ export { collectUsedStyleRuleIds, treeShakeStyleRules, treeShakeStyleRulesBySignature, - usedStyleRuleIdSignature, + createUsedStyleRuleIdSelector, } from './styleRuleTreeShake' export { buildSiteFrameworkCss, generateFrameworkCss } from './frameworkCss' diff --git a/src/core/publisher/styleRuleTreeShake.ts b/src/core/publisher/styleRuleTreeShake.ts index 854b15e79..0994b5410 100644 --- a/src/core/publisher/styleRuleTreeShake.ts +++ b/src/core/publisher/styleRuleTreeShake.ts @@ -27,7 +27,7 @@ let lastScriptRuns: Set = new Set() * live site with nothing to point at. */ function scriptIdentifierRuns(files: SiteDocument['files']): Set { - // `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 @@ -68,24 +68,61 @@ export function collectUsedStyleRuleIds( } } + addScriptStyleRuleIds(usedIds, site) + return usedIds +} + +function addScriptStyleRuleIds( + usedIds: Set, + site: Pick, +): 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, -): 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>() + let previousSite: Pick | null = null + let signature = '' + function collect(nodes: SiteDocument['pages'][number]['nodes']): Set { + const cached = trees.get(nodes) + if (cached) return cached + const ids = new Set() + 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() + 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(