diff --git a/src/element_style.py b/src/element_style.py index 5a0864d8..0285406f 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -780,8 +780,14 @@ def _adopt_handwritten_block(schema: Dict[str, Any], # editor matched them by exact name, so it drew one offset in eleven # and the rest had no control anywhere. Recording the answer here # keeps the alias rules in one place instead of a JavaScript copy. + # Only an object-shaped entry (x_offset/y_offset/...) can hold an + # element's offsets. A leaf directly under layout -- a show_logo + # toggle -- is its own control, so it is never claimed by a row and + # always gets a position row of its own. layout_key = next((name for name in alias_keys(key) - if isinstance(layout_fields.get(name), dict)), None) + if isinstance(layout_fields.get(name), dict) + and isinstance(layout_fields[name].get('properties'), + dict)), None) if layout_key is not None: props[key]['x-layout-key'] = layout_key diff --git a/test/js/README.md b/test/js/README.md index 023e0d9a..c635ce80 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -36,6 +36,9 @@ nothing is listening, so it stays useful in a bare checkout. |---|---|---| | `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it | | `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata | +| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer | +| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row | +| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | | `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` | diff --git a/test/js/run_all.js b/test/js/run_all.js index b10a9827..99ddaa01 100755 --- a/test/js/run_all.js +++ b/test/js/run_all.js @@ -15,7 +15,9 @@ const fs = require('fs'); const BASE = process.env.BASE || 'http://localhost:5000'; const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js', - 'unit/test_html_escaping.js']; + 'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js', + 'unit/test_style_editor_layout_leaf_columns.js', + 'unit/test_style_editor_layout_leaf_collision.js']; const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', 'dom/test_tools_sections.js']; diff --git a/test/js/unit/test_style_editor_element_keys.js b/test/js/unit/test_style_editor_element_keys.js new file mode 100644 index 00000000..b3d22462 --- /dev/null +++ b/test/js/unit/test_style_editor_element_keys.js @@ -0,0 +1,126 @@ +// Regression test for how style-editor.js decides which rows to draw, +// extracted verbatim from the shipped widget (own/ownObj/elementKeys/ +// styleRows/positionRows have no DOM dependency) so the test can't drift from +// the real implementation. +// +// render() claims the whole `layout` child of a customization schema as the +// widget's own -- removing it from the generic fallback renderer entirely, +// because posting the same offset twice from two controls is worse than +// owning slightly too much. That is only safe when every entry under `layout` +// gets exactly one row: +// +// * styleRows(): one per style element, paired with the layout entry that +// holds its offsets. The pairing is not by exact name -- a hand-written +// schema styles 'score_text' but positions 'score' -- so core records it +// as x-layout-key, resolved through the same alias map the renderer reads +// offsets with. +// * positionRows(): one per layout entry no style element claimed -- a logo, +// a timeout indicator, a possession arrow, or a bare leaf like a +// show_logo toggle. +// +// This contract replaced an earlier one in which elementKeys() itself +// appended layout-only keys. That compared names exactly, so an aliased entry +// ('score' for 'score_text') was listed a second time and its offsets got two +// controls posting the same field. +const fs = require('fs'); +const path = require('path'); +const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); + +const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8'); +const start = src.indexOf('function own(obj, key)'); +const endMark = 'function control(opts)'; +const end = src.indexOf(endMark); +if (start === -1 || end === -1) { + console.error('FAIL: could not locate own()..positionRows() in style-editor.js -- slice markers need updating'); + process.exit(1); +} +eval(src.slice(start, end)); // defines own/ownObj/elementKeys/styleRows/positionRows + +let failures = 0; +function ok(desc, cond) { + if (cond) { console.log(`ok - ${desc}`); } + else { console.log(`FAIL - ${desc}`); failures++; } +} + +function layoutKeysOf(rows) { return rows.map(function (r) { return r.layoutKey; }); } + +// score_text is styled and positioned under an alias; odds_text has no +// offsets at all; possession is positioned but never styled; show_logo is a +// bare leaf under layout. +const schema = { + properties: { + score_text: { + type: 'object', 'x-style-managed': true, 'x-layout-key': 'score', + properties: { font: { type: 'string' }, text_color: { type: 'array' } }, + }, + odds_text: { + type: 'object', 'x-style-managed': true, + properties: { font: { type: 'string' } }, + }, + layout: { + type: 'object', + 'x-propertyOrder': ['score', 'possession', 'show_logo'], + properties: { + score: { type: 'object', properties: { x_offset: {}, y_offset: {} } }, + possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } }, + show_logo: { type: 'boolean', default: true }, + }, + }, + }, +}; + +const keys = elementKeys(schema); +ok('elementKeys lists the style elements', keys.join(',') === 'score_text,odds_text'); +ok('layout and modes containers are never treated as elements', + keys.indexOf('layout') === -1 && keys.indexOf('modes') === -1); + +const styled = styleRows(schema); +ok('a style element is paired with the layout entry core resolved for it', + styled[0].key === 'score_text' && styled[0].layoutKey === 'score'); +ok('a style element with no offsets claims nothing', styled[1].layoutKey === null); + +const positions = positionRows(schema); +ok('a layout-only object entry gets a position row', + layoutKeysOf(positions).indexOf('possession') !== -1); +ok('a layout-only leaf gets a position row too', + layoutKeysOf(positions).indexOf('show_logo') !== -1); +ok('an entry claimed through an alias is not listed again as a position', + layoutKeysOf(positions).indexOf('score') === -1); +ok('positions follow the declared order', + layoutKeysOf(positions).join(',') === 'possession,show_logo'); + +const every = layoutKeysOf(styled).filter(Boolean).concat(layoutKeysOf(positions)); +ok('every layout entry gets exactly one row', + every.length === new Set(every).size + && every.slice().sort().join(',') === ['possession', 'score', 'show_logo'].join(',')); + +// A schema with no layout block at all must behave exactly as before -- +// the overwhelmingly common case must not gain a phantom row. +const noLayout = { + properties: { + score_text: { + type: 'object', 'x-style-managed': true, + properties: { font: { type: 'string' } }, + }, + }, +}; +ok('a schema without a layout block has no positions', + elementKeys(noLayout).join(',') === 'score_text' && positionRows(noLayout).length === 0); + +// Without an annotation (the compact declaration, or a schema that never went +// through expansion) the two blocks share one key. +const unannotated = { + properties: { + home_logo: { type: 'object', 'x-style-managed': true, properties: {} }, + layout: { + type: 'object', + properties: { home_logo: { type: 'object', properties: { x_offset: {}, scale: {} } } }, + }, + }, +}; +ok('an exact-name layout entry is claimed without an annotation', + styleRows(unannotated)[0].layoutKey === 'home_logo' + && positionRows(unannotated).length === 0); + +if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); } +console.log('\nall checks passed'); diff --git a/test/js/unit/test_style_editor_layout_leaf_collision.js b/test/js/unit/test_style_editor_layout_leaf_collision.js new file mode 100644 index 00000000..c128f242 --- /dev/null +++ b/test/js/unit/test_style_editor_layout_leaf_collision.js @@ -0,0 +1,76 @@ +// Regression test for style-editor.js's columnsFor(), extracted verbatim +// from the shipped widget (see test_style_editor_layout_leaf_columns.js for +// the base case this builds on). +// +// A layout-only leaf key (e.g. a bare "scale" toggle directly under +// `layout`, no x/y object underneath) used to be keyed in the internal +// `seen` map by its own bare field name. If some *other* element's style +// block, or another element's layout axis block, happened to declare a +// sub-field with that exact same name (e.g. "scale" is also a real +// COLUMN_ORDER axis name most elements use), the `!seen.has(key)` guard +// skipped creating the leaf's column -- it silently reused the existing +// 'element'/'layout' column instead. That column's row binding in +// elementRow() then looked the field up under the wrong schema location for +// the leaf's own row, found nothing, and rendered a blank cell: the same +// "silently disappears" bug the leaf-column fix was meant to close, just +// reached through a name collision instead of a missing column altogether. +// This pins the fix: layout-leaf columns are keyed under a namespaced id so +// they can never be shadowed by an unrelated column sharing their name. +const fs = require('fs'); +const path = require('path'); +const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); + +const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8'); +const start = src.indexOf('function own(obj, key)'); +const endMark = 'function control(opts)'; +const end = src.indexOf(endMark); +if (start === -1 || end === -1) { + console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating'); + process.exit(1); +} +eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor + +let failures = 0; +function ok(desc, cond) { + if (cond) { console.log(`ok - ${desc}`); } + else { console.log(`FAIL - ${desc}`); failures++; } +} + +// `possession` is a real style element whose layout axis block declares a +// "scale" sub-field, which seeds seen.set('scale', 'layout'). `timeout_flag` +// is layout-only and a bare leaf valued directly under layout, but its own +// key is *also* "scale" -- an unrelated collision with that same name. +const schema = { + properties: { + possession: { + type: 'object', + 'x-style-managed': true, + properties: { font: { type: 'string' } }, + }, + scale: { + // The collision: a second element literally named "scale", entirely + // unrelated to the layout-only leaf below sharing that string. + type: 'boolean', + }, + layout: { + type: 'object', + properties: { + possession: { type: 'object', properties: { scale: { type: 'number' } } }, + scale: { type: 'boolean', default: true }, + }, + }, + }, +}; + +const columns = columnsFor(schema); +const leafColumns = columns.filter(function (c) { return c.where === 'layout-leaf'; }); + +ok('the colliding layout axis sub-field still gets its own column', + columns.some(function (c) { return c.key === 'scale' && c.where === 'layout'; })); +ok('the layout-only leaf key still gets its own column despite the name collision', + leafColumns.length === 1 && leafColumns[0].key === 'scale'); +ok('the leaf column is distinct from the colliding element/layout column', + columns.filter(function (c) { return c.key === 'scale'; }).length === 2); + +if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); } +console.log('\nall checks passed'); diff --git a/test/js/unit/test_style_editor_layout_leaf_columns.js b/test/js/unit/test_style_editor_layout_leaf_columns.js new file mode 100644 index 00000000..4d495ace --- /dev/null +++ b/test/js/unit/test_style_editor_layout_leaf_columns.js @@ -0,0 +1,89 @@ +// Regression test for style-editor.js's columnsFor(), extracted verbatim +// from the shipped widget (own/ownObj/elementKeys/columnsFor have no DOM +// dependency) so the test can't drift from the real implementation. +// +// elementKeys() already appends a layout-only key that never got its own +// top-level style block (see test_style_editor_element_keys.js), so table() +// draws a row for it. But render() still claims the whole `layout` child as +// the widget's own regardless of what that row actually shows -- and +// columnsFor() only produced a column for a layout-only key whose own value +// is an *object* with sub-fields (x_offset, y_offset, ...). A hand-written +// schema can instead put a plain leaf value directly under layout -- a +// "show_logo" toggle, a timeout indicator's on/off flag -- with no x/y +// object underneath it. That key still got a row (data-element="show_logo"), +// but zero columns ever matched it, so the row rendered with every cell +// blank: no control, and no way back to the fallback since layout was +// removed wholesale. This pins the fix: such a key gets its own +// self-keyed 'layout-leaf' column. +const fs = require('fs'); +const path = require('path'); +const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); + +const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8'); +const start = src.indexOf('function own(obj, key)'); +const endMark = 'function control(opts)'; +const end = src.indexOf(endMark); +if (start === -1 || end === -1) { + console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating'); + process.exit(1); +} +eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor + +let failures = 0; +function ok(desc, cond) { + if (cond) { console.log(`ok - ${desc}`); } + else { console.log(`FAIL - ${desc}`); failures++; } +} + +// score_text is a real style element; possession is layout-only but shaped +// like an object (x/y); show_logo is layout-only and a bare leaf value. +const schema = { + properties: { + score_text: { + type: 'object', + 'x-style-managed': true, + properties: { font: { type: 'string' }, text_color: { type: 'array' } }, + }, + layout: { + type: 'object', + properties: { + score_text: { type: 'object', properties: { x_offset: {}, y_offset: {} } }, + possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } }, + show_logo: { type: 'boolean', default: true }, + }, + }, + }, +}; + +const columns = columnsFor(schema); +const byKey = {}; +columns.forEach(function (c) { byKey[c.key] = c; }); + +ok('an element style field still gets its own column', byKey.font && byKey.font.where === 'element'); +ok('a layout-only object key still contributes its x/y columns', + byKey.x_offset && byKey.x_offset.where === 'layout'); +ok('a layout-only leaf key gets a column keyed to itself', + !!byKey.show_logo); +ok('...marked as a layout-leaf column, not a shared sub-field', + byKey.show_logo && byKey.show_logo.where === 'layout-leaf'); +ok('nothing is duplicated', columns.length === new Set(columns.map(function (c) { return c.key; })).size); + +// A schema with no leaf-valued layout key must not gain a phantom column -- +// this is the common case (every existing scoreboard) and must be unaffected. +const noLeaf = { + properties: { + score_text: { + type: 'object', 'x-style-managed': true, + properties: { font: { type: 'string' } }, + }, + layout: { + type: 'object', + properties: { score_text: { type: 'object', properties: { x_offset: {} } } }, + }, + }, +}; +ok('a schema with only object-shaped layout keys gets no layout-leaf column', + columnsFor(noLeaf).every(function (c) { return c.where !== 'layout-leaf'; })); + +if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); } +console.log('\nall checks passed'); diff --git a/test/web_interface/test_style_editor_takeover.py b/test/web_interface/test_style_editor_takeover.py index 645f644f..5d6618d7 100644 --- a/test/web_interface/test_style_editor_takeover.py +++ b/test/web_interface/test_style_editor_takeover.py @@ -88,6 +88,16 @@ class TestItTakesOverOnlyItsOwnBlocks: entirely styling looks exactly as it did before.""" assert "!fallback.querySelector('[data-child-key]')" in source + def test_the_owned_key_is_escaped_before_it_becomes_a_selector(self, source): + """ownedKeys comes back off a dataset attribute, not a literal -- + splicing it into '[data-child-key="' + k + '"]' unescaped breaks (or + is steerable) on a key containing a quote or backslash. The rest of + this codebase (plugin-file-manager.js, app-shell.js) always routes a + dynamic value through CSS.escape() before it lands in a selector.""" + assert "CSS.escape(k)" in source, ( + "an owned key must be CSS.escape()'d before being interpolated " + "into the data-child-key attribute selector") + class TestTheSchemaSaysWhichBlocksAreStyling: def test_adopted_blocks_are_marked_and_others_are_not(self): @@ -221,3 +231,21 @@ class TestEveryAdvertisedOffsetGetsAControl: "the alias rules live in element_style.py; the widget must read " "their result rather than carry a second copy") assert "function positionRows" in js + + def test_a_leaf_under_layout_is_never_claimed_as_offsets(self): + """A show_logo toggle straight under layout has no x/y object, so it + cannot hold an element's offsets. Claiming it would give that row no + layout columns and remove the leaf from the positions list -- the + field would lose its only control.""" + from src.element_style import expand_style_elements + + schema = _football_shaped() + cust = schema["properties"]["customization"] + # A style element whose alias ("show_logo") names a leaf. + cust["properties"]["show_logo_text"] = _style_block() + cust["properties"]["layout"]["properties"]["show_logo"] = { + "type": "boolean", "default": True} + props = expand_style_elements(schema)["properties"]["customization"][ + "properties"] + assert "x-layout-key" not in props["show_logo_text"] + assert "show_logo" in props["layout"]["x-propertyOrder"] diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index 71b2616d..c495cd6d 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -367,13 +367,6 @@ visible: '3.5rem' }; - /** - * The columns a table needs, derived from the schema rather than fixed. - * - * Two sources: the sub-fields elements declare (font, font_size, - * text_color, visible, align) and the sub-fields their layout blocks - * declare (x_offset, y_offset, scale). - */ /** * Where an element's offsets live in the layout block, or null. * @@ -388,10 +381,13 @@ var props = (schema && schema.properties) || {}; var layoutProps = ownObj(props, 'layout').properties || {}; var declared = ownObj(props, key)['x-layout-key']; - if (typeof declared === 'string' && own(layoutProps, declared)) { + // Only an object-shaped entry holds offsets. A leaf straight under + // layout (a show_logo toggle) is a control of its own, so it is never + // claimed here and always gets a position row. + if (typeof declared === 'string' && ownObj(layoutProps, declared).properties) { return declared; } - return own(layoutProps, key) ? key : null; + return ownObj(layoutProps, key).properties ? key : null; } /** One row per styled element, paired with its offsets if it has any. */ @@ -415,7 +411,11 @@ var claimed = styleRows(schema).map(function (r) { return r.layoutKey; }); var order = layout['x-propertyOrder'] || Object.keys(layoutProps); return order.filter(function (lk) { - return ownObj(layoutProps, lk).properties && claimed.indexOf(lk) === -1; + var entry = own(layoutProps, lk); + // Object-shaped entries carry offsets; a leaf is itself the + // control. Both need a row here, because the editor removes the + // fallback's whole layout section. + return entry && typeof entry === 'object' && claimed.indexOf(lk) === -1; }).map(function (lk) { return { key: null, layoutKey: lk }; }); @@ -426,9 +426,21 @@ return ownObj(layoutProps, layoutKey).title || layoutKey.replace(/_/g, ' '); } + /** + * The columns a table needs, derived from the schema rather than fixed. + * + * Three sources: the sub-fields a row's style block declares (font, + * font_size, text_color, visible, align), the sub-fields of its layout + * entry (x_offset, y_offset, scale), and a layout entry whose own value + * *is* the field -- a show_logo toggle straight under layout has no x/y + * object underneath, so it gets a column keyed to itself. + * + * `rows` defaults to every row the schema produces, style and position. + */ function columnsFor(schema, rows) { var props = schema.properties || {}; var layoutProps = ownObj(props, 'layout').properties || {}; + rows = rows || styleRows(schema).concat(positionRows(schema)); // A Map, not an object: the keys are field names out of a schema, so // a field literally named "constructor" is a column like any other // and never touches a prototype. @@ -438,9 +450,16 @@ Object.keys(ownObj(props, row.key).properties || {}).forEach( function (f) { seen.set(f, 'element'); }); } - if (row.layoutKey) { - Object.keys(ownObj(layoutProps, row.layoutKey).properties || {}).forEach( + if (!row.layoutKey) { return; } + var entry = own(layoutProps, row.layoutKey); + if (entry && typeof entry === 'object' && entry.properties) { + Object.keys(entry.properties).forEach( function (f) { seen.set(f, 'layout'); }); + } else if (entry && typeof entry === 'object') { + // A leaf. Namespaced so an unrelated column sharing its name + // -- another entry's "scale" axis, say -- can never shadow it + // and leave this row's only control blank. + seen.set('layout-leaf:' + row.layoutKey, 'layout-leaf'); } }); var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); }); @@ -450,10 +469,12 @@ return COLUMN_ORDER.indexOf(f) === -1; }).sort(); return known.concat(extra).map(function (f) { + var isLeaf = f.indexOf('layout-leaf:') === 0; + var fieldKey = isLeaf ? f.slice('layout-leaf:'.length) : f; return { - key: f, + key: fieldKey, where: seen.get(f), - label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ') + label: own(COLUMN_LABELS, fieldKey) || fieldKey.replace(/_/g, ' ') }; }); } @@ -528,8 +549,12 @@ } opts.columns.forEach(function (col) { + var isLeaf = col.where === 'layout-leaf'; var inLayout = col.where === 'layout'; - var prop = inLayout ? own(axes, col.key) : own(props, col.key); + // A leaf column belongs to the one position row named after it; + // every other row leaves that cell blank. + var prop = isLeaf ? (col.key === layoutKey ? own(layoutProps, layoutKey) : null) + : inLayout ? own(axes, col.key) : own(props, col.key); var cell; if (!prop) { // This element does not declare that field; keep the grid @@ -537,16 +562,19 @@ row.appendChild(el('span')); return; } - var path = inLayout ? ['layout', layoutKey, col.key] : [key, col.key]; + var path = isLeaf ? ['layout', layoutKey] + : inLayout ? ['layout', layoutKey, col.key] : [key, col.key]; // Posted under the key the schema declares, never the style key: // a plugin's own offset reader looks up layout.score, so a value - // saved as layout.score_text would be kept and never drawn. - var base = inLayout ? opts.layoutPrefix + '.' + layoutKey - : opts.prefix + '.' + key; + // saved as layout.score_text would be kept and never drawn. A + // leaf is the field itself, so its name has no sub-field suffix -- + // the same name the generic renderer would have posted. + var base = (isLeaf || inLayout) ? opts.layoutPrefix + '.' + layoutKey + : opts.prefix + '.' + key; var node = control({ key: col.key, prop: prop, - name: base + '.' + col.key, + name: isLeaf ? base : base + '.' + col.key, current: effective(value, path, prop, optional), optional: optional, fonts: opts.fonts, diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 0df79f8d..fc7909fb 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -159,8 +159,13 @@ var owned = (container.dataset.ownedKeys || '') .split(',').filter(function (k) { return k; }); owned.forEach(function (k) { + // CSS.escape: k is a schema property key read + // back off dataset.ownedKeys, not a literal -- + // building the attribute selector by raw + // concatenation would break (or be steerable) + // on a key containing a quote or backslash. var node = fallback.querySelector( - '[data-child-key="' + k + '"]'); + '[data-child-key="' + CSS.escape(k) + '"]'); if (node) { node.remove(); } }); // Nothing left but the section heading, or a widget