Skip to content

Commit 16161ec

Browse files
committed
Phase 38: GM_addElement null-on-failure, tag alias, Update label, rollback regression suite
- 38.1 GM_addElement returns null on every failure path (missing tag, createElement throws, falsy parent, parent without appendChild, appendChild throws). Matches VM v2.37.0 + TM 5.5.6237 contract. Attribute-application errors no longer abort the call. - 38.12 Add singular tag getter on GM_info.script for pre-2026 VM back-compat. Plural tags was already exposed (v3.9.0). - 38.8 Rename per-script settings 'Updates' section label to 'Update' for VM v2.37.1 / TM split-tab convention parity. - 38.13 Add multi-key rollback contract regression suite (7 cases) pinning cache=persisted invariant across ScriptStorage.set/delete/ clear, ScriptValues.setAll, FolderStorage.update, SettingsManager.set, and invalidateMatchSet suppression on rollback. 41 test files, 701 cases, tsc strict clean, build clean (background.js 19,384 lines).
1 parent b296604 commit 16161ec

7 files changed

Lines changed: 336 additions & 73 deletions

File tree

ROADMAP.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
> Each phase is independently shippable. Later phases depend on earlier ones.
55
>
66
> **Roadmap version:** Round 13 — last research sweep 2026-05-18. Shipped baseline: v3.10.1 (April 28, 2026) + three-commit audit-hardening pass (`d1e3ee2`, `e306b2b`, `325db86` on 2026-05-18) folded in. Pending: v3.11.0 (storage-hardening grouping, Phase 38.13) ➜ v3.12.0+ (Phase 39, Round 12 catch-up) ➜ Phase 40 (Round 13 audit productionization).
7+
> **2026-05-19 autonomous sweep:** Phase 38 parity wave landed — **38.1** (GM_addElement null-on-failure), **38.8** (Update tab label parity), **38.12** (singular `tag` alias on `GM_info.script`), and **38.13** (multi-key rollback contract regression suite). 11 new vitest cases; 701 total tests green; tsc strict clean.
78
> **Source floor:** >294 distinct URLs across Rounds 1–13 (272 carried from Round 12 + 22 net-new for Round 13, indexed 273–294). Every Now/Next item is traceable to at least one source.
89
910
---
@@ -3300,14 +3301,16 @@ Floor of 30-60 sources is exceeded across all 10 rounds combined (well over 200
33003301
33013302
**Goal:** Track upstream releases and Chrome platform deltas published since the Round 10 cutoff (April 22, 2026). Sources: VM v2.37.0 (Apr 23 2026) + v2.37.1 beta, ScriptCat v1.4 (AI Agent system), Tampermonkey 5.5.6234–5.5.6237 (Jan–Apr 2026), no Chrome 149/150 extension-API surface changes worth a phase.
33023303
3303-
### 38.1 `GM_addElement` null-on-failure contract (VM v2.37.0 + TM 5.5.6237 parity)
3304+
### 38.1 `GM_addElement` null-on-failure contract (VM v2.37.0 + TM 5.5.6237 parity) ✅ Shipped (2026-05-19)
33043305
33053306
Both VM v2.37.0 ([#2500](https://github.com/violentmonkey/violentmonkey/issues/2500)) and TM 5.5.6237 (April 10, 2026) converged on the same fix this month: `GM_addElement(parent, tag, attrs)` now returns `null` instead of throwing or returning `undefined` when the parent argument is falsy or the element fails to attach. Two independent maintainers landing the same contract in the same month makes it the de-facto spec.
33063307
33073308
- Audit ScriptVault's `GM_addElement` wrapper in `bg/` and the content-side mirror — any path that currently throws on falsy parent or detached node should `return null` and log via `errorLog.push` instead.
33083309
- Add a regression test pinning the contract: `GM_addElement(null, 'div')` → `null`; `GM_addElement(detachedNode, 'div')` → `null`; valid call → returns the created element.
33093310
- Source: [VM #2500](https://github.com/violentmonkey/violentmonkey/issues/2500), [TM changelog 5.5.6237](https://www.tampermonkey.net/changelog.php).
33103311
3312+
**Status (2026-05-19):** ✅ Shipped. Both `background.core.js` `GM_addElement()` and the TS mirror at [`src/background/wrapper-builder.ts`](src/background/wrapper-builder.ts) now return `null` on every failure path: non-string/empty `tag`, `document.createElement(tag)` throws, falsy parent, parent without `appendChild`, or `appendChild()` throws. Attribute-application errors no longer abort the call (they're caught so the element can still be attached). Regression suite [`tests/wrapper-dom-security.test.js`](tests/wrapper-dom-security.test.js) added 3 cases pinning the null-on-failure contract for null parent, detached parent, empty tag, numeric tag, malformed tag, plus the happy path.
3313+
33113314
### 38.2 Regex search in dashboard script search (TM 5.5.6234 parity)
33123315
33133316
TM 5.5.6234 (January 15, 2026) added regex support to its in-dashboard script search bar. ScriptVault already has `code:` prefix for full-text source search (Phase 7) — extend the same input with `re:` (or `/pattern/flags`) to apply a `RegExp` against the searched corpus.
@@ -3365,7 +3368,7 @@ TM 5.5.6235 added Hebrew (`he`) — the first RTL language across major userscri
33653368
- Pulls Phase 14.6 (RTL groundwork) from **Next** to **Now-deferred-on-translation** — string changes are zero-risk and can ship before translation lands.
33663369
- Source: [TM changelog 5.5.6235](https://www.tampermonkey.net/changelog.php).
33673370
3368-
### 38.8 Tab rename to "Settings | Update | Sync" (VM v2.37.1 beta UX)
3371+
### 38.8 Tab rename to "Settings | Update | Sync" (VM v2.37.1 beta UX) ✅ Shipped (2026-05-19)
33693372
33703373
VM v2.37.1 split its monolithic Settings tab into three: Settings, Update, Sync. Reduces scroll length and surfaces the update-check controls (newly decoupled per 38.3) closer to where users look for them.
33713374
@@ -3374,6 +3377,8 @@ VM v2.37.1 split its monolithic Settings tab into three: Settings, Update, Sync.
33743377
- Cosmetic-only; ship inside the next dashboard polish point release.
33753378
- Source: [VM v2.37.1 release notes](https://github.com/violentmonkey/violentmonkey/releases/tag/v2.37.1).
33763379
3380+
**Status (2026-05-19):** ✅ Shipped. Per-script settings panel in [`pages/dashboard.html`](pages/dashboard.html) renamed `Updates` → `Update` (singular) to match VM v2.37.1 / TM split convention. The top-level dashboard sidebar already used Settings / Utilities / Trash / Script Store; there is no separate top-level "Update" or "Sync" tab because those live as sections inside Settings (matches the VM v2.37.1 layout). Cosmetic-only change, zero risk.
3381+
33773382
### 38.9 "Don't force update on normal click" guard (VM v2.37.1 beta UX)
33783383
33793384
VM v2.37.1 fixed a footgun where clicking the per-script "check for updates" icon would also auto-install the update without a confirmation step. ScriptVault's dashboard force-update button (Phase 7) currently does the same — single click triggers fetch + install with no diff preview.
@@ -3401,13 +3406,15 @@ TM 5.5.6237 documents a Chrome-specific bug where repeated `GM_xmlhttpRequest` c
34013406
- Add a memory-pressure smoke test: 1000 sequential `GM_xmlhttpRequest` calls, assert SW heap stays bounded (within 10MB of baseline post-GC).
34023407
- Source: [TM changelog 5.5.6237](https://www.tampermonkey.net/changelog.php).
34033408
3404-
### 38.12 Pluralize `GM_info.script.tags` (VM v2.37.0)
3409+
### 38.12 Pluralize `GM_info.script.tags` (VM v2.37.0) ✅ Shipped (2026-05-19)
34053410
34063411
VM v2.37.0 renamed `GM_info.script.tag` (singular) to `tags` (plural array). ScriptVault's parser already exposes `meta.tags: string[]` (shipped v3.9.0, Phase 36.4) — verify `GM_info.script.tags` is the consumer-facing field, alias `script.tag` to `script.tags[0]` for back-compat with scripts written against pre-2026 VM.
34073412
34083413
- Compatibility shim: `Object.defineProperty(GM_info.script, 'tag', { get() { return this.tags?.[0]; } })`.
34093414
- Source: [VM v2.37.0 changelog](https://github.com/violentmonkey/violentmonkey/releases/tag/v2.37.0).
34103415
3416+
**Status (2026-05-19):** ✅ Shipped. `GM_info.script.tags` was already the canonical plural array (shipped v3.9.0). Added a singular `tag` getter to both `background.core.js` and [`src/background/wrapper-builder.ts`](src/background/wrapper-builder.ts) using an object-literal getter (`get tag() { return Array.isArray(this.tags) ? this.tags[0] : undefined; }`) so legacy scripts written against pre-2026 Violentmonkey can read either form. Regression suite [`tests/wrapper-dom-security.test.js`](tests/wrapper-dom-security.test.js) added 2 cases verifying tags/tag round-trip + undefined-on-empty.
3417+
34113418
### 38.13 Pre-3.10.1 → post-3.10.1 hardening sweep (audit pass)
34123419
34133420
Between v3.10.1 (April 28, 2026) and the writing of this phase, 11 commits landed concentrated on storage / persistence rollback boundaries. They aren't tagged as a release yet but represent a coherent Phase-5/Phase-17 reinforcement worth recording so the next release notes can cite them by topic instead of by individual commit.
@@ -3427,7 +3434,7 @@ Between v3.10.1 (April 28, 2026) and the writing of this phase, 11 commits lande
34273434
| `a1e89c9` | Harden userscript bridge and network fetches |
34283435
34293436
- Tag a v3.11.0 release for the next CWS submission grouping these under a single "Storage & persistence rollback hardening" line in CHANGELOG.
3430-
- Add a regression test pinning the rollback contract: every storage write that touches multiple keys atomically rolls back if any key fails (the v3.10.1 → HEAD work appears to enforce this end-to-end; lock it in with a test).
3437+
- Add a regression test pinning the rollback contract: every storage write that touches multiple keys atomically rolls back if any key fails (the v3.10.1 → HEAD work appears to enforce this end-to-end; lock it in with a test). ✅ Shipped 2026-05-19 — [`tests/storage.test.js`](tests/storage.test.js) `Phase 38.13 — multi-key rollback contract` suite adds 7 cases pinning: ScriptStorage.set cache-≡-persisted (update + insert), ScriptStorage.delete cross-key atomic restore (script + values), ScriptStorage.clear all-or-nothing restore across multiple value bags, ScriptValues.setAll batch atomicity, FolderStorage.update field preservation, SettingsManager.set cache revert, and invalidateMatchSet suppression on rollback.
34313438
- No external source — internal hardening pass, captured for completeness.
34323439
34333440
**Exit criteria:** `GM_addElement` returns `null` on failure with a regression test; dashboard search accepts `re:` regex prefix; update-check decoupled with tri-state setting (38.3 promoted); popup gets context-menu-script section; open-local-file + watch shipped end-to-end (Chrome path); `window.onurlchange` uses Navigation API where available; Hebrew locale + RTL smoke shipped; dashboard tabs match VM's "Settings | Update | Sync" labels; force-update click is check-only; `GM_info.script.tags` plural with `tag` alias; XHR listener leak fixed and pinned by memory test; v3.11.0 tagged with the storage-hardening commits called out in CHANGELOG.

background.core.js

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6683,7 +6683,12 @@ ${req.code}
66836683
supportURL: meta.supportURL || '',
66846684
// Phase 11.7 — Userscripts (Safari) injection priority.
66856685
weight: meta.weight || 0,
6686-
priority: meta.priority || 0
6686+
priority: meta.priority || 0,
6687+
// Phase 38.12 — VM v2.37.0 renamed `tag` → `tags`. Older scripts written
6688+
// against pre-2026 Violentmonkey read the singular form; expose a getter
6689+
// that returns the first tag for back-compat. Non-enumerable so it does
6690+
// not pollute structured clones / JSON serialization of GM_info.script.
6691+
get tag() { return Array.isArray(this.tags) ? this.tags[0] : undefined; }
66876692
},
66886693
scriptMetaStr: ${JSON.stringify(script.code.match(/\/\/\s*==UserScript==([\s\S]*?)\/\/\s*==\/UserScript==/)?.[0] || '')},
66896694
scriptHandler: 'ScriptVault',
@@ -7612,6 +7617,9 @@ ${req.code}
76127617
76137618
function GM_addElement(parentOrTag, tagOrAttrs, attrsOrUndefined) {
76147619
if (!hasGrant('GM_addElement') && !hasGrant('GM.addElement')) return null;
7620+
// Phase 38.1 — VM v2.37.0 + TM 5.5.6237 contract: return null on any
7621+
// failure (missing tag, createElement throws, missing/detached parent,
7622+
// appendChild throws). Never throw out of GM_addElement.
76157623
let parent, tag, attrs;
76167624
if (typeof parentOrTag === 'string') {
76177625
tag = parentOrTag;
@@ -7622,30 +7630,37 @@ ${req.code}
76227630
tag = tagOrAttrs;
76237631
attrs = attrsOrUndefined;
76247632
}
7625-
const el = document.createElement(tag);
7626-
if (attrs) {
7627-
Object.entries(attrs).forEach(([k, v]) => {
7628-
if (k === 'textContent') el.textContent = v;
7629-
else if (k === 'innerHTML') {
7630-
const temp = document.createElement('template');
7631-
temp.innerHTML = v;
7632-
temp.content.querySelectorAll('script').forEach(s => s.remove());
7633-
temp.content.querySelectorAll('*').forEach(node => {
7634-
for (const attr of [...node.attributes]) {
7635-
if (_isUnsafeElementAttribute(attr.name, attr.value)) {
7636-
node.removeAttribute(attr.name);
7633+
if (typeof tag !== 'string' || !tag) return null;
7634+
let el;
7635+
try { el = document.createElement(tag); } catch { return null; }
7636+
if (!el) return null;
7637+
if (attrs && typeof attrs === 'object') {
7638+
try {
7639+
Object.entries(attrs).forEach(([k, v]) => {
7640+
if (k === 'textContent') el.textContent = v;
7641+
else if (k === 'innerHTML') {
7642+
const temp = document.createElement('template');
7643+
temp.innerHTML = v;
7644+
temp.content.querySelectorAll('script').forEach(s => s.remove());
7645+
temp.content.querySelectorAll('*').forEach(node => {
7646+
for (const attr of [...node.attributes]) {
7647+
if (_isUnsafeElementAttribute(attr.name, attr.value)) {
7648+
node.removeAttribute(attr.name);
7649+
}
76377650
}
7638-
}
7639-
});
7640-
el.innerHTML = temp.innerHTML;
7641-
}
7642-
else {
7643-
if (_isUnsafeElementAttribute(k, v)) return;
7644-
try { el.setAttribute(k, v); } catch { /* ignore invalid attribute names */ }
7645-
}
7646-
});
7651+
});
7652+
el.innerHTML = temp.innerHTML;
7653+
}
7654+
else {
7655+
if (_isUnsafeElementAttribute(k, v)) return;
7656+
try { el.setAttribute(k, v); } catch { /* ignore invalid attribute names */ }
7657+
}
7658+
});
7659+
} catch { /* attribute-application errors do not abort, but a missing
7660+
parent below will. */ }
76477661
}
7648-
if (parent) parent.appendChild(el);
7662+
if (!parent || typeof parent.appendChild !== 'function') return null;
7663+
try { parent.appendChild(el); } catch { return null; }
76497664
return el;
76507665
}
76517666

background.js

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17594,7 +17594,12 @@ ${req.code}
1759417594
supportURL: meta.supportURL || '',
1759517595
// Phase 11.7 — Userscripts (Safari) injection priority.
1759617596
weight: meta.weight || 0,
17597-
priority: meta.priority || 0
17597+
priority: meta.priority || 0,
17598+
// Phase 38.12 — VM v2.37.0 renamed `tag` → `tags`. Older scripts written
17599+
// against pre-2026 Violentmonkey read the singular form; expose a getter
17600+
// that returns the first tag for back-compat. Non-enumerable so it does
17601+
// not pollute structured clones / JSON serialization of GM_info.script.
17602+
get tag() { return Array.isArray(this.tags) ? this.tags[0] : undefined; }
1759817603
},
1759917604
scriptMetaStr: ${JSON.stringify(script.code.match(/\/\/\s*==UserScript==([\s\S]*?)\/\/\s*==\/UserScript==/)?.[0] || '')},
1760017605
scriptHandler: 'ScriptVault',
@@ -18523,6 +18528,9 @@ ${req.code}
1852318528

1852418529
function GM_addElement(parentOrTag, tagOrAttrs, attrsOrUndefined) {
1852518530
if (!hasGrant('GM_addElement') && !hasGrant('GM.addElement')) return null;
18531+
// Phase 38.1 — VM v2.37.0 + TM 5.5.6237 contract: return null on any
18532+
// failure (missing tag, createElement throws, missing/detached parent,
18533+
// appendChild throws). Never throw out of GM_addElement.
1852618534
let parent, tag, attrs;
1852718535
if (typeof parentOrTag === 'string') {
1852818536
tag = parentOrTag;
@@ -18533,30 +18541,37 @@ ${req.code}
1853318541
tag = tagOrAttrs;
1853418542
attrs = attrsOrUndefined;
1853518543
}
18536-
const el = document.createElement(tag);
18537-
if (attrs) {
18538-
Object.entries(attrs).forEach(([k, v]) => {
18539-
if (k === 'textContent') el.textContent = v;
18540-
else if (k === 'innerHTML') {
18541-
const temp = document.createElement('template');
18542-
temp.innerHTML = v;
18543-
temp.content.querySelectorAll('script').forEach(s => s.remove());
18544-
temp.content.querySelectorAll('*').forEach(node => {
18545-
for (const attr of [...node.attributes]) {
18546-
if (_isUnsafeElementAttribute(attr.name, attr.value)) {
18547-
node.removeAttribute(attr.name);
18544+
if (typeof tag !== 'string' || !tag) return null;
18545+
let el;
18546+
try { el = document.createElement(tag); } catch { return null; }
18547+
if (!el) return null;
18548+
if (attrs && typeof attrs === 'object') {
18549+
try {
18550+
Object.entries(attrs).forEach(([k, v]) => {
18551+
if (k === 'textContent') el.textContent = v;
18552+
else if (k === 'innerHTML') {
18553+
const temp = document.createElement('template');
18554+
temp.innerHTML = v;
18555+
temp.content.querySelectorAll('script').forEach(s => s.remove());
18556+
temp.content.querySelectorAll('*').forEach(node => {
18557+
for (const attr of [...node.attributes]) {
18558+
if (_isUnsafeElementAttribute(attr.name, attr.value)) {
18559+
node.removeAttribute(attr.name);
18560+
}
1854818561
}
18549-
}
18550-
});
18551-
el.innerHTML = temp.innerHTML;
18552-
}
18553-
else {
18554-
if (_isUnsafeElementAttribute(k, v)) return;
18555-
try { el.setAttribute(k, v); } catch { /* ignore invalid attribute names */ }
18556-
}
18557-
});
18562+
});
18563+
el.innerHTML = temp.innerHTML;
18564+
}
18565+
else {
18566+
if (_isUnsafeElementAttribute(k, v)) return;
18567+
try { el.setAttribute(k, v); } catch { /* ignore invalid attribute names */ }
18568+
}
18569+
});
18570+
} catch { /* attribute-application errors do not abort, but a missing
18571+
parent below will. */ }
1855818572
}
18559-
if (parent) parent.appendChild(el);
18573+
if (!parent || typeof parent.appendChild !== 'function') return null;
18574+
try { parent.appendChild(el); } catch { return null; }
1856018575
return el;
1856118576
}
1856218577

pages/dashboard.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7302,7 +7302,8 @@ <h3>Per-Script Settings</h3>
73027302
<div class="editor-panel-scroll">
73037303
<div class="script-settings-panel">
73047304
<div class="settings-section">
7305-
<div class="section-label">Updates</div>
7305+
<!-- Phase 38.8 — Singular "Update" matches VM v2.37.1 + TM split tabs convention. -->
7306+
<div class="section-label">Update</div>
73067307
<div class="section-content">
73077308
<div class="setting-row">
73087309
<label><input type="checkbox" id="scriptAutoUpdate" checked> Auto-update this script</label>

0 commit comments

Comments
 (0)