Skip to content

Commit da38f12

Browse files
authored
Merge pull request #804 from Automattic/fix/region-effect-analyzer-fail-closed
fix(blocks-engine): make region-effect analyzer fail closed and typed
2 parents 08ab64a + dee3784 commit da38f12

5 files changed

Lines changed: 152 additions & 41 deletions

File tree

packages/blocks-engine/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
This package uses [Semantic Versioning](https://semver.org/). Deprecations are warned one minor version ahead of removal.
1010

11+
## [Unreleased]
12+
13+
### Fixed
14+
15+
- `analyzeRuntimeRegionEffects` (unreleased) now fails closed on unparseable source — the manifest carries a single whole-source unit with `reason: 'parse_failed'` instead of an empty, effect-free-looking unit list — and its shared-state detection registers every binding a top-level statement contributes outside function bodies (destructuring, `function`/`class` declarations, loop heads, nested blocks), which previously escaped it and could mark shared-state effects as independently suppressible. `getElementById` targets that are not plain CSS identifiers are emitted as escaped `[id="…"]` selectors.
16+
1117
## [0.2.2] - 2026-06-30
1218

1319
### Added

packages/blocks-engine/src/escape.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/lib/html-escape.ts
22
//
3-
// The single home for HTML entity escaping. Three escalating variants: pick by
4-
// context, not convenience.
3+
// The single home for HTML entity and CSS selector escaping. Pick the variant
4+
// by context, not convenience.
55

66
/** Escape &, <, >: the minimal set for HTML text-node content. */
77
export function escapeHtmlText(s: string): string {
@@ -17,3 +17,20 @@ export function escapeHtmlAttr(s: string): string {
1717
export function escapeHtml(s: string): string {
1818
return escapeHtmlAttr(s).replace(/'/g, '&#039;');
1919
}
20+
21+
// CSS selector escaping, for synthesizing selectors from attribute values.
22+
23+
/** True when `value` is a plain CSS identifier usable directly after `#`. */
24+
export function cssSimpleIdent(value: string): boolean {
25+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value);
26+
}
27+
28+
/** Escape a value for embedding in a double-quoted CSS attribute selector. */
29+
export function escapeCssAttrValue(value: string): string {
30+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
31+
}
32+
33+
/** Selector for an element id: `#id` when plain, else `tag[id="…"]`. */
34+
export function cssIdSelector(id: string, tag = ''): string {
35+
return cssSimpleIdent(id) ? `#${id}` : `${tag}[id="${escapeCssAttrValue(id)}"]`;
36+
}

packages/blocks-engine/src/runtime/region-effect-manifest.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,31 @@ describe('analyzeRuntimeRegionEffects', () => {
1515
const dynamic = analyzeRuntimeRegionEffects(`document.querySelector(selector).addEventListener('click', () => {});`);
1616
expect(dynamic.units[0].reason).toBe('dynamic_selector');
1717
});
18+
19+
it('fails closed on unparseable source instead of reporting it effect-free', () => {
20+
const broken = analyzeRuntimeRegionEffects(`document.querySelector('.carousel'`);
21+
expect(broken.units).toHaveLength(1);
22+
expect(broken.units[0].status).toBe('shared_or_unsplittable');
23+
expect(broken.units[0].reason).toBe('parse_failed');
24+
expect(broken.units[0].source).toMatchObject({ start: 0, end: broken.units[0].source.end });
25+
const inert = analyzeRuntimeRegionEffects(`/* nothing to do */`);
26+
expect(inert.units).toEqual([]);
27+
});
28+
29+
it('fails closed for state shared through destructuring, function declarations, and loop heads', () => {
30+
const destructured = analyzeRuntimeRegionEffects(`const { active } = window.state;\ndocument.querySelector('.carousel').addEventListener('click', () => active.toggle());`);
31+
expect(destructured.units[1].reason).toBe('shared_state');
32+
const arrayPattern = analyzeRuntimeRegionEffects(`let [first] = window.items;\ndocument.querySelector('.reveal').addEventListener('click', () => first.show());`);
33+
expect(arrayPattern.units[1].reason).toBe('shared_state');
34+
const declaredFunction = analyzeRuntimeRegionEffects(`function advance() {}\ndocument.querySelector('.carousel').addEventListener('click', () => advance());`);
35+
expect(declaredFunction.units[1].reason).toBe('shared_state');
36+
const loopHead = analyzeRuntimeRegionEffects(`for (const step of window.steps) { window.register(step); }\ndocument.querySelector('.carousel').addEventListener('click', () => window.play(step));`);
37+
expect(loopHead.units[1].reason).toBe('shared_state');
38+
});
39+
40+
it('escapes getElementById targets that are not plain CSS identifiers', () => {
41+
const manifest = analyzeRuntimeRegionEffects(`document.getElementById('hero').addEventListener('click', () => {});\ndocument.getElementById('2col "grid"').addEventListener('click', () => {});`);
42+
expect(manifest.units[0].targets).toEqual(['#hero']);
43+
expect(manifest.units[1].targets).toEqual(['[id="2col \\"grid\\""]']);
44+
});
1845
});
Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createHash } from 'node:crypto';
22
import { parse } from 'acorn';
3+
import type { AnyNode, Pattern, Program } from 'acorn';
4+
import { cssIdSelector } from '../escape.js';
35

46
export type RuntimeEffectUnit = {
57
id: string;
@@ -9,7 +11,7 @@ export type RuntimeEffectUnit = {
911
mutations: string[];
1012
dependencies: string[];
1113
status: 'independently_suppressible' | 'shared_or_unsplittable';
12-
reason?: 'dynamic_selector' | 'shared_state' | 'unrecognized_pattern';
14+
reason?: 'dynamic_selector' | 'shared_state' | 'unrecognized_pattern' | 'parse_failed';
1315
};
1416

1517
export type RegionEffectManifest = {
@@ -18,39 +20,99 @@ export type RegionEffectManifest = {
1820
units: RuntimeEffectUnit[];
1921
};
2022

23+
const SCHEMA = 'blocks-engine/runtime-region-effects/v1' as const;
24+
25+
const CLASS_MUTATORS = new Set(['add', 'remove', 'toggle', 'replace']);
26+
2127
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
2228

2329
/**
2430
* Produces an ownership manifest from top-level DOM-effect statements. This is
25-
* deliberately bounded: unsupported AST shapes remain retained as a whole.
31+
* deliberately bounded: unsupported AST shapes remain retained as a whole, and
32+
* unparseable source yields a single whole-source unretirable unit rather than
33+
* an empty (effect-free-looking) manifest.
2634
*/
2735
export function analyzeRuntimeRegionEffects(source: string): RegionEffectManifest {
2836
const sourceHash = hash(source);
29-
let program: any;
37+
let program: Program;
3038
try {
3139
program = parse(source, { ecmaVersion: 'latest', sourceType: 'script' });
3240
} catch {
33-
return { schema: 'blocks-engine/runtime-region-effects/v1', sourceHash, units: [] };
41+
return {
42+
schema: SCHEMA,
43+
sourceHash,
44+
units: [
45+
{
46+
id: `effect_1_${sourceHash.slice(0, 12)}`,
47+
source: { start: 0, end: source.length, hash: sourceHash },
48+
targets: [],
49+
events: [],
50+
mutations: [],
51+
dependencies: [],
52+
status: 'shared_or_unsplittable',
53+
reason: 'parse_failed',
54+
},
55+
],
56+
};
3457
}
3558

36-
const declared = new Map<string, number>();
37-
for (const statement of program.body) {
38-
if (statement.type === 'VariableDeclaration') {
39-
for (const declaration of statement.declarations) {
40-
if (declaration.id.type === 'Identifier') declared.set(declaration.id.name, (declared.get(declaration.id.name) ?? 0) + 1);
41-
}
42-
}
43-
}
59+
const declared = new Set<string>();
60+
for (const statement of program.body) collectSharedBindings(statement, declared);
4461

4562
return {
46-
schema: 'blocks-engine/runtime-region-effects/v1',
63+
schema: SCHEMA,
4764
sourceHash,
48-
units: program.body.map((statement: any, index: number) => unitFor(statement, index, source, declared)),
65+
units: program.body.map((statement, index) => unitFor(statement, index, source, declared)),
4966
};
5067
}
5168

52-
function unitFor(statement: any, index: number, source: string, declared: Map<string, number>): RuntimeEffectUnit {
69+
/**
70+
* Registers every binding a top-level statement can contribute to program
71+
* scope: declarations in nested blocks and loop heads included, function
72+
* bodies excluded (their bindings are local). Block-scoped bindings in nested
73+
* blocks over-collect, which only fails closed.
74+
*/
75+
function collectSharedBindings(value: unknown, names: Set<string>): void {
76+
if (Array.isArray(value)) {
77+
for (const child of value) collectSharedBindings(child, names);
78+
return;
79+
}
80+
if (!isAstNode(value)) return;
81+
if ((value.type === 'FunctionDeclaration' || value.type === 'ClassDeclaration') && value.id) names.add(value.id.name);
82+
if (value.type === 'FunctionDeclaration' || value.type === 'FunctionExpression' || value.type === 'ArrowFunctionExpression') return;
83+
if (value.type === 'VariableDeclaration') {
84+
for (const declaration of value.declarations) collectPatternNames(declaration.id, names);
85+
}
86+
for (const child of Object.values(value)) collectSharedBindings(child, names);
87+
}
88+
89+
function collectPatternNames(pattern: Pattern, names: Set<string>): void {
90+
switch (pattern.type) {
91+
case 'Identifier':
92+
names.add(pattern.name);
93+
break;
94+
case 'ObjectPattern':
95+
for (const property of pattern.properties) {
96+
collectPatternNames(property.type === 'RestElement' ? property.argument : property.value, names);
97+
}
98+
break;
99+
case 'ArrayPattern':
100+
for (const element of pattern.elements) {
101+
if (element) collectPatternNames(element, names);
102+
}
103+
break;
104+
case 'RestElement':
105+
collectPatternNames(pattern.argument, names);
106+
break;
107+
case 'AssignmentPattern':
108+
collectPatternNames(pattern.left, names);
109+
break;
110+
}
111+
}
112+
113+
function unitFor(statement: AnyNode, index: number, source: string, declared: Set<string>): RuntimeEffectUnit {
53114
const slice = source.slice(statement.start, statement.end);
115+
const sliceHash = hash(slice);
54116
const targets = new Set<string>();
55117
const events = new Set<string>();
56118
const mutations = new Set<string>();
@@ -65,32 +127,37 @@ function unitFor(statement: any, index: number, source: string, declared: Map<st
65127
recognized = true;
66128
const argument = node.arguments[0];
67129
if (argument.type !== 'Literal' || typeof argument.value !== 'string') dynamicSelector = true;
68-
else targets.add(name === 'getElementById' ? `#${argument.value}` : argument.value);
130+
else targets.add(name === 'getElementById' ? cssIdSelector(argument.value) : argument.value);
69131
}
70132
if (name === 'addEventListener' && node.arguments[0]?.type === 'Literal' && typeof node.arguments[0].value === 'string') {
71133
recognized = true;
72134
events.add(node.arguments[0].value);
73135
}
74-
if (['add', 'remove', 'toggle', 'replace'].includes(name) && node.callee.object?.type === 'MemberExpression' && node.callee.object.property?.name === 'classList') mutations.add('class');
136+
if (CLASS_MUTATORS.has(name) && node.callee.object.type === 'MemberExpression' && node.callee.object.property.type === 'Identifier' && node.callee.object.property.name === 'classList') mutations.add('class');
75137
if (name === 'setAttribute' && node.arguments[0]?.type === 'Literal' && typeof node.arguments[0].value === 'string') mutations.add(`attribute:${node.arguments[0].value}`);
76138
});
77-
const shared = [...declared.keys()].some((name) => identifiers.has(name));
78-
const reason = dynamicSelector ? 'dynamic_selector' : shared ? 'shared_state' : !recognized || !targets.size ? 'unrecognized_pattern' : undefined;
139+
const dependencies = [...identifiers].filter((name) => declared.has(name)).sort();
140+
const reason = dynamicSelector ? 'dynamic_selector' : dependencies.length ? 'shared_state' : !recognized || !targets.size ? 'unrecognized_pattern' : undefined;
79141
const unit: RuntimeEffectUnit = {
80-
id: `effect_${index + 1}_${hash(slice).slice(0, 12)}`,
81-
source: { start: statement.start, end: statement.end, hash: hash(slice) },
142+
id: `effect_${index + 1}_${sliceHash.slice(0, 12)}`,
143+
source: { start: statement.start, end: statement.end, hash: sliceHash },
82144
targets: [...targets].sort(), events: [...events].sort(), mutations: [...mutations].sort(),
83-
dependencies: [...identifiers].filter((name) => declared.has(name)).sort(),
145+
dependencies,
84146
status: reason ? 'shared_or_unsplittable' : 'independently_suppressible',
85147
};
86148
return reason ? { ...unit, reason } : unit;
87149
}
88150

89-
function walk(node: any, visit: (node: any) => void) {
90-
if (!node || typeof node !== 'object' || typeof node.type !== 'string') return;
91-
visit(node);
92-
for (const value of Object.values(node)) {
93-
if (Array.isArray(value)) value.forEach((child) => walk(child, visit));
94-
else walk(value, visit);
151+
function isAstNode(value: unknown): value is AnyNode {
152+
return !!value && typeof value === 'object' && typeof (value as { type?: unknown }).type === 'string';
153+
}
154+
155+
function walk(value: unknown, visit: (node: AnyNode) => void): void {
156+
if (Array.isArray(value)) {
157+
for (const child of value) walk(child, visit);
158+
return;
95159
}
160+
if (!isAstNode(value)) return;
161+
visit(value);
162+
for (const child of Object.values(value)) walk(child, visit);
96163
}

packages/blocks-engine/src/theme/section-extract.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as cheerio from 'cheerio';
22
import type { CheerioAPI } from 'cheerio';
33
import type { InteractionModel, SectionSpec, SectionSpecImage } from './section-spec.js';
44
import type { SitePage } from './types.js';
5+
import { cssIdSelector, escapeCssAttrValue } from '../escape.js';
56

67
type ElementNode = NonNullable<Parameters<CheerioAPI>[0]> & {
78
type?: string;
@@ -341,16 +342,16 @@ function parseDimension(value: string | undefined): number {
341342
function selectorForElement($: CheerioAPI, el: ElementNode): string {
342343
const tag = tagName(el);
343344
const id = attr(el, 'id');
344-
if (id) return simpleIdent(id) ? `#${id}` : `${tag}[id="${escapeAttr(id)}"]`;
345+
if (id) return cssIdSelector(id, tag);
345346

346347
const labelledBy = attr(el, 'aria-labelledby');
347-
if (labelledBy) return `${tag}[aria-labelledby="${escapeAttr(labelledBy)}"]`;
348+
if (labelledBy) return `${tag}[aria-labelledby="${escapeCssAttrValue(labelledBy)}"]`;
348349

349350
const ariaLabel = attr(el, 'aria-label');
350-
if (ariaLabel) return `${tag}[aria-label="${escapeAttr(ariaLabel)}"]`;
351+
if (ariaLabel) return `${tag}[aria-label="${escapeCssAttrValue(ariaLabel)}"]`;
351352

352353
const role = attr(el, 'role');
353-
if (role) return `${tag}[role="${escapeAttr(role)}"]`;
354+
if (role) return `${tag}[role="${escapeCssAttrValue(role)}"]`;
354355

355356
if (!/^h[1-6]$/.test(tag) && $(tag).length === 1) return tag;
356357

@@ -408,10 +409,3 @@ function pushUnique(values: string[], text: string): void {
408409
if (text && !values.includes(text)) values.push(text);
409410
}
410411

411-
function simpleIdent(value: string): boolean {
412-
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value);
413-
}
414-
415-
function escapeAttr(value: string): string {
416-
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
417-
}

0 commit comments

Comments
 (0)