A Zero-innerHTML Client Architecture eliminates cross-site scripting (XSS) sinks and untyped string concatenation by replacing imperative .innerHTML mutations with declarative Preact virtual DOM islands [Mil20], reactive Nanostores state atoms, and static ESLint and filesystem gates that mechanically forbid raw HTML DOM injection across all client scripts [Wei11] [WS23].
| Architectural Dimension | Imperative .innerHTML Templates | Full-Page SPA Hydration | Preact/Nanostores Islands + Zero-innerHTML AST Gate (SYSTEM-009) |
|---|---|---|---|
| DOM XSS Surface | High (manual escapeHtml() or unescaped template strings) [Wei11] | Low (automatic VDOM text escaping), but large runtime footprint | Zero raw HTML sinks; 100% contextual VDOM escaping + CI AST ban [WS23] |
| Client JS Payload | 0 KB framework overhead, but high inline script duplication | 40–130+ KB global hydration bundle across static prose | ~3 KB Preact runtime scoped strictly to interactive islands [Mil20] |
| State Synchronization | Fragile window.* globals, .className overwrites, cloneNode(true) | Monolithic context providers and route-level re-renders | Tiny reactive atom and map stores in stores.ts |
| Enforcement Mechanism | Code review discipline only (prone to regression) | Framework conventions (bypassable via dangerouslySetInnerHTML) | Unbypassable ESLint no-restricted-properties + full-tree regex gate |
What Structural Flaws Did Our 49-Script Client Audit Reveal?
Auditing all 49 client-side scripts in SYSTEM-009 exposed two systemic liabilities: 7 duplicated ~75-line ES5 <script is:inline> pre-filter blocks across catalog pages and 6 raw .innerHTML sinks—including unescaped node metadata in graph tooltips and 600 lines of string-concatenated SVG/HTML in our analytics Sankey inspector [Wei11].
To eliminate copy-paste drift before touching the rendering layer, Phase 1 consolidated the 7 inline ES5 URL pre-filters (?tag=, ?q=, ?page=, ?lang=, ?trl=) into a single parameterized CatalogPreFilter.astro component and unified 4 scattered clipboard scripts into clipboard.ts with WeakMap timer state and astro:page-load lifecycle support. We then replaced all 6 imperative .innerHTML sinks with typed Preact .tsx islands [Mil20]:
Replacing string-concatenated DOM templates with typed Preact .tsx islands guarantees automatic contextual text escaping across every tooltip, pagination bar, and analytics inspector while shrinking duplicated inline scripts.
How Do Preact Islands Coexist with Server-Side React Email?
We configure "jsxImportSource": "preact" globally in tsconfig.json so interactive client .tsx islands compile into lightweight Preact virtual DOM nodes, while explicitly annotating our four server-side newsletter templates under src/commons/server/email/ with /** @jsxImportSource react */ compiler pragmas and synchronizing cross-island browser state through tiny, framework-agnostic Nanostores reactive state atoms [Mil20].
This Dual-JSX routing keeps server-only @react-email/components rendering in our Newsletter-as-Code Listmonk Pipeline untouched while allowing interactive browser components—such as pagination.tsx, vis-tooltip.tsx (powering our Five Graph Engines Unified Interaction Physics and Voronoi Proximity Picking), and dev-sankey-view.tsx (visualizing Consent-Free Plausible Markov Visitor Flows)—to share a tiny ~3 KB VDOM runtime and centralized reactive state in stores.ts that hardens the DOM against Emergent AI Security Incidents and In-Browser WebMCP Injection Vectors.
The simplified excerpt below shows how stores.ts and pagination.tsx combine Nanostores atoms and maps with Preact’s render() entrypoint:
// src/commons/client/stores.ts & pagination.tsx (simplified)
import { atom, map } from 'nanostores';
import { render } from 'preact';
export const $themeSetting = atom<'system' | 'light' | 'dark'>('system');
export const $paginationPages = map<Record<string, number>>({});
export function mountPaginationNumbers(
container: HTMLElement,
navId: string,
pages: (number | string)[],
currentPage: number
): void {
render(
<>
{pages.map((p, i) =>
typeof p === 'string' ? <span key={i}>...</span> : <button key={p} data-goto={p}>{p}</button>
)}
</>,
container
);
$paginationPages.setKey(navId, currentPage);
}Enforcing a Zero-innerHTML Policy via ESLint and AST Gates
Because manual code review alone cannot prevent future contributors or autonomous AI agents from reintroducing .innerHTML mutations, we enforce a two-layer mechanical gate: AST-level ESLint restrictions in eslint.config.ts coupled with a full-directory source scanner in validate-architecture.ts that also inspects raw <script is:inline> blocks [Wei11] [WS23].
Because eslint.config.ts simultaneously sets 'eslint-comments/no-use': ['error', { allow: [] }], neither human developers nor autonomous coding agents can suppress the ban with eslint-disable comments:
// eslint.config.ts — Unbypassable Zero-innerHTML AST Gate
'no-restricted-properties': [
'error',
{
property: 'innerHTML',
message: 'Direct .innerHTML assignment is forbidden. Use Preact JSX (.tsx) or textContent/replaceChildren.',
},
{ property: 'outerHTML', message: 'Direct .outerHTML assignment is forbidden.' },
],
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.property.name='insertAdjacentHTML']",
message: 'insertAdjacentHTML() is forbidden. Use Preact JSX (.tsx) or safe DOM APIs.',
},
],References
- [Wei11]Joel Weinberger, Prateek Saxena, Devdatta Akhawe, Matthew Finifter, Richard Shin and Dawn Song (2011). A Systematic Analysis of XSS Sanitization in Web Application Frameworks. In Computer Security – ESORICS 2011 (LNCS, vol. 6879), pp. 150--171. DOI: 10.1007/978-3-642-23822-2_9.
- [Mil20]Jason Miller (2020). Islands Architecture. jasonformat.com. Available online at https://jasonformat.com/islands-architecture/.
- [WS23]Mike West and Antonio Sartori (2023). Content Security Policy Level 3. W3C Working Draft. Available online at https://www.w3.org/TR/CSP3/.