What's new in 1.14.0
View gained parseDirective / ParsedDirective, new directives bq-once, bq-init, bq-pre, bq-cloak, bq-html-safe, bq-memo, and the full bq-on modifier system in 1.14.0. See the 1.14.0 release notes.
The view module provides declarative DOM bindings similar to Vue/Svelte templates, but without requiring a compiler. Bindings are evaluated at runtime using bQuery's reactive system. Internally, the view module is now split into focused submodules while the public API remains unchanged.
import { mount } from '@bquery/bquery/view';
import { signal, computed } from '@bquery/bquery/reactive';Basic Usage
<div id="app">
<input bq-model="name" />
<p bq-text="greeting"></p>
</div>const name = signal('World');
const greeting = computed(() => `Hello, ${name.value}!`);
const view = mount('#app', { name, greeting });Directives
bq-text
Binds text content:
<p bq-text="message"></p>
<span bq-text="count + ' items'"></span>bq-html
Binds innerHTML (sanitized by default):
<div bq-html="richContent"></div>Sanitization can be disabled (use with caution):
mount('#app', { content }, { sanitize: false });bq-if
Conditional rendering (removes/inserts element):
<div bq-if="isLoggedIn">Welcome back!</div>
<div bq-if="!isLoggedIn">Please log in.</div>bq-show
Toggle visibility via CSS display:
<div bq-show="isVisible">This toggles display: none</div>bq-class
Dynamic class binding:
<!-- Object syntax -->
<div bq-class="{ active: isActive, disabled: isDisabled }"></div>
<!-- Expression returning string -->
<div bq-class="currentTheme"></div>
<!-- Expression returning array -->
<div bq-class="[baseClass, conditionalClass]"></div>bq-style
Dynamic inline styles:
<!-- Object syntax -->
<div bq-style="{ color: textColor, fontSize: size + 'px' }"></div>
<!-- Expression returning object -->
<div bq-style="computedStyles"></div>bq-model
Two-way binding for inputs:
<!-- Text input -->
<input bq-model="username" />
<!-- Checkbox -->
<input type="checkbox" bq-model="isChecked" />
<!-- Radio buttons -->
<input type="radio" value="a" bq-model="selected" />
<input type="radio" value="b" bq-model="selected" />
<!-- Select -->
<select bq-model="selectedOption">
<option value="1">One</option>
<option value="2">Two</option>
</select>bq-error
Render validation or error messages from a form field, signal, computed value, or plain expression.
<!-- Bind a field object -->
<p bq-error="form.fields.email"></p>
<!-- Bind the error signal directly -->
<p bq-error="form.fields.email.error"></p>
<!-- Bind any reactive string source -->
<p bq-error="serverError"></p>bq-error sets textContent, hides the element when the message is empty, applies aria-hidden="true" only when the message is empty and you did not already provide aria-hidden, and adds accessible defaults with role="alert" and aria-live="assertive" unless you already provided those attributes.
const serverError = signal('');
mount('#app', { form, serverError });bq-aria
Bind ARIA attributes from an object literal or an expression that returns an object.
<!-- Object syntax -->
<button bq-aria="{ expanded: isOpen, controls: panelId, label: buttonLabel }">Toggle menu</button>
<!-- Expression returning an object -->
<nav bq-aria="navAria"></nav>bq-aria automatically prefixes keys with aria-, removes attributes for null, undefined, or empty strings, serializes booleans as "true" / "false", and keeps previously applied ARIA attributes in sync when the object shape changes.
const navAria = signal({
label: 'Primary navigation',
current: 'page',
});
mount('#app', { isOpen, panelId, buttonLabel, navAria });bq-bind:attr
Bind any attribute:
<a bq-bind:href="url" bq-bind:title="tooltip">Link</a>
<img bq-bind:src="imageSrc" bq-bind:alt="imageAlt" />
<button bq-bind:disabled="isDisabled">Submit</button>Falsy values remove the attribute:
<input bq-bind:required="isRequired" />
<!-- If isRequired is false, the 'required' attribute is removed -->bq-on:event
Event binding:
<button bq-on:click="handleClick">Click me</button>
<input bq-on:input="updateValue" bq-on:blur="validate" />
<form bq-on:submit="handleSubmit">...</form>Access the event object with $event:
<button bq-on:click="handleClick($event)">Click</button> <input bq-on:keydown="onKey($event)" />Access the element with $el:
<input bq-on:focus="onFocus($el)" />Event listeners also support common modifiers such as .stop, .prevent, .self, .capture, .passive, and .once. When .prevent is present, passive mode is disabled automatically so event.preventDefault() still works.
bq-cloak
Use bq-cloak to hide pre-hydration or pre-mount markup until the view reaches that element:
<div bq-cloak>
<p bq-text="message"></p>
</div>[bq-cloak] {
display: none;
}The attribute is removed during processing so the subtree becomes visible once the view has mounted.
bq-pre
Use bq-pre to skip directive processing for an element and its descendants:
<section bq-pre>
<code bq-text="left-as-authored"></code>
</section>This is useful for code samples or third-party DOM islands that should remain untouched. If bq-cloak is also present on the same element, the cloak marker is still removed even though the subtree is skipped.
bq-for
List rendering with optional keyed reconciliation for optimal DOM reuse:
<!-- Basic -->
<ul>
<li bq-for="item in items" bq-text="item.name"></li>
</ul>
<!-- With index -->
<ul>
<li bq-for="(item, index) in items">
<span bq-text="index + 1"></span>:
<span bq-text="item.name"></span>
</li>
</ul>
<!-- With key for efficient updates (recommended for dynamic lists) -->
<ul>
<li bq-for="item in items" :key="item.id" bq-text="item.name"></li>
</ul>Keyed Reconciliation
When items in a list have unique identifiers, use the :key attribute to enable efficient DOM updates. This is similar to Vue's v-for with :key or React's key prop.
Without a key: Elements are matched by index. If items are reordered, all affected DOM nodes are recreated.
With a key: Elements are matched by their unique key. If items are reordered, existing DOM nodes are moved rather than recreated, preserving component state and improving performance.
<!-- Using :key (preferred shorthand) -->
<li bq-for="user in users" :key="user.id" bq-text="user.name"></li>
<!-- Alternative: bq-key -->
<li bq-for="user in users" bq-key="user.id" bq-text="user.name"></li>When to Use Keys
Always use :key when:
- List items can be added, removed, or reordered
- Items have associated state (like form inputs)
- Items contain expensive child components
- The list is frequently updated
Keys should be:
- Unique within the list
- Stable (same item → same key across updates)
- Not based on array index (defeats the purpose)
const users = signal([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]);
mount('#app', { users });
// Reordering preserves DOM elements:
users.value = [
{ id: 3, name: 'Charlie' },
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];bq-ref
Element reference:
<input bq-ref="inputEl" />const inputEl = signal<HTMLInputElement | null>(null);
mount('#app', { inputEl });
// After mount, inputEl.value is the <input> element
inputEl.value?.focus();Mounting
mount()
Mount a view to an existing element:
const view = mount('#app', {
name: signal('World'),
greeting: computed(() => `Hello, ${name.value}!`),
handleClick: () => console.log('Clicked!'),
});With options:
const view = mount('#app', context, {
prefix: 'x', // Use x-text instead of bq-text
sanitize: false, // Disable HTML sanitization
});View Instance
The returned view object:
type View = {
el: Element; // The root element
context: BindingContext; // The binding context
update: (newContext: Partial<BindingContext>) => void;
destroy: () => void; // Cleanup all effects
};Updating Context
const view = mount('#app', { count: signal(0) });
// Add new values to context
view.update({
newValue: signal('hello'),
});Cleanup
Always destroy views when done:
view.destroy();Clearing Expression Cache
The view module caches compiled expressions for performance. In rare cases (e.g., testing or dynamic template changes), you may want to clear this cache:
import { clearExpressionCache } from '@bquery/bquery/view';
// Clear all cached expression functions
clearExpressionCache();When to Use
You typically don't need to call this. It's mainly useful for:
- Test environments that mount/unmount many views
- Hot module replacement (HMR) scenarios
- Memory-constrained applications with many dynamic templates
Templates
Create reusable template functions:
import { createTemplate } from '@bquery/bquery/view';
const TodoItem = createTemplate(`
<li bq-class="{ completed: done }">
<input type="checkbox" bq-model="done" />
<span bq-text="text"></span>
</li>
`);
// Create instances
const item1 = TodoItem({
done: signal(false),
text: 'Buy groceries',
});
const item2 = TodoItem({
done: signal(true),
text: 'Walk the dog',
});
document.querySelector('#list')!.append(item1.el, item2.el);
// Cleanup
item1.destroy();
item2.destroy();Custom Prefix
Use a custom directive prefix:
<div id="app">
<p x-text="message"></p>
<div x-if="showDetails">Details</div>
</div>mount('#app', context, { prefix: 'x' });Expressions
Directives accept JavaScript expressions:
<!-- Arithmetic -->
<span bq-text="count + 1"></span>
<!-- Ternary -->
<span bq-text="isActive ? 'Active' : 'Inactive'"></span>
<!-- Method calls -->
<span bq-text="items.length"></span>
<!-- Template literals (with proper escaping) -->
<span bq-text="`Total: ${total}`"></span>Integration with Components
Use view bindings inside Web Components:
import { component, html } from '@bquery/bquery/component';
import { mount, View } from '@bquery/bquery/view';
import { signal } from '@bquery/bquery/reactive';
component('counter-app', {
view: null as View | null,
connected() {
const count = signal(0);
this.view = mount(this.shadowRoot!, {
count,
increment: () => count.value++,
});
},
disconnected() {
this.view?.destroy();
},
render() {
return html`
<div>
<span bq-text="count"></span>
<button bq-on:click="increment()">+</button>
</div>
`;
},
});Type Reference
type BindingContext = Record<string, unknown>;
type MountOptions = {
prefix?: string; // Default: 'bq'
sanitize?: boolean; // Default: true
};
type View = {
el: Element;
context: BindingContext;
update: (newContext: Partial<BindingContext>) => void;
destroy: () => void;
};parseDirective()
parseDirective() is exported for tooling and advanced integrations that need to inspect directive syntax without mounting a view.
Pass the directive name after removing the bq- prefix:
import { parseDirective } from '@bquery/bquery/view';
const parsed = parseDirective('on:click.stop.prevent');
console.log(parsed.directive); // 'on'
console.log(parsed.arg); // 'click'
console.log(parsed.modifiers.has('stop')); // true
console.log(parsed.modParams); // {}It also supports parameterized modifiers such as debounce-300:
const parsed = parseDirective('model.debounce-300.trim');
console.log(parsed.modParams.debounce); // '300'Security Considerations
Expression Evaluation Warning
The view module uses new Function() to evaluate directive expressions at runtime. This is similar to how Vue and Alpine.js work, but carries important security implications.
What This Means
When you write:
<span bq-text="user.name"></span>The expression user.name is evaluated dynamically at runtime using JavaScript's new Function() constructor. This is essentially equivalent to eval() in terms of security.
Safe Usage
✅ DO use expressions from developer-controlled templates:
<!-- In your HTML file or template literal -->
<div bq-if="isLoggedIn" bq-text="username"></div>✅ DO sanitize context values that come from users:
const userInput = signal(sanitizeHtml(untrustedInput));
mount('#app', { userInput });Unsafe Usage
❌ NEVER use expressions derived from user input:
// DANGEROUS! Never do this:
const userExpression = getUserInput(); // e.g., "alert('hacked')"
element.setAttribute('bq-text', userExpression);
mount(element, context);❌ NEVER load templates with bq-* attributes from untrusted sources:
// DANGEROUS! Template could contain malicious expressions:
const template = await fetch('/api/user-template').then((r) => r.text());
container.innerHTML = template;
mount(container, context); // Malicious bq-on:click expressions could executeIf You Need User-Generated Templates
If your application requires loading templates from external sources:
- Validate attribute values before mounting - strip or escape bq-* attributes
- Use an allowlist of permitted expressions
- Consider a sandboxed approach using iframes for truly untrusted content
- Use static bindings with sanitized values instead of dynamic expressions:
// Instead of allowing bq-text="userExpression"
// Use a safe static binding:
element.textContent = sanitizeHtml(userValue);Why Not Use a Safer Parser?
A fully sandboxed expression parser would:
- Add significant bundle size
- Reduce expression flexibility
- Still require careful security review
The current approach matches industry standards (Vue, Alpine, Angular) while keeping the implementation focused and predictable. The key is ensuring expressions come only from trusted sources.
Directive reference (1.14.0)
| Directive | Purpose |
|---|---|
bq-text | Set element text content from an expression. |
bq-html / bq-html-safe | Render HTML — bq-html-safe sanitizes before insertion. |
bq-model | Two-way binding for inputs, selects, textareas, checkboxes. |
bq-show / bq-if | Toggle visibility or DOM mounting on a condition. |
bq-for | Render lists from arrays / iterables; supports keyed reconciliation via :key / bq-key. |
bq-on:event[.mods] | Event handler with modifier matrix (.prevent, .stop, .once, .passive, .capture, .self, .left, .right, .middle, key filters). |
bq-class / bq-style | Reactive class / style objects. |
bq-attr / bq-aria | Reactive attribute / ARIA bag. |
bq-once | Run the binding exactly once, then untrack. |
bq-init | Execute an expression on mount only. |
bq-pre | Skip directive parsing in this subtree (preserve literal source). |
bq-cloak | Hide until the view is mounted to prevent FOUC. |
bq-memo | Memoize a subtree by a reactive key. |
bq-error | Per-subtree error boundary for binding failures. |
Pitfalls and gotchas
- View expressions run through
new Function(...)— your CSP must allow'unsafe-eval'or you must use the strict-mode allow-list. bq-htmlsanitizes when thesanitizemount option istrue(the default);bq-html-safesanitizes unconditionally — prefer it for untrusted content to guard against accidentalsanitize: false.bq-forrequires stable keys for reordering — supply:key="item.id"(orbq-key="item.id") to avoid re-creating subtrees.bq-modelon<select multiple>always reads/writes an array, not a single string.- Call
destroy()on theViewreturned bymount()when removing dynamic views — leaked bindings keep signals subscribed.
Performance notes
- Wrap expensive subtrees in
bq-memokeyed on their inputs. - Use
bq-oncefor write-once data (translations, server-injected props) to skip per-update work. - Cache compiled templates with
createTemplate()when instantiating many copies.
Testing this module
- Import
mount()from@bquery/bquery/view, then pair it withscreen,within(),fireEvent.*, andtick()from@bquery/bquery/testingto drive views inbun:test. - Combine with
mockSignal()to assert reactivity at the directive level.
Related modules
- Reactive — the signal layer view bindings subscribe to.
- Component — declarative components that compose views.
- Security — sanitizer, Trusted Types, and CSP guidance for
bq-html. - Plugin — register custom directives (
tooltip,tooltip:arrow, …).
Version history
- 1.14.0 —
parseDirective,ParsedDirective, new directivesbq-once,bq-init,bq-pre,bq-cloak,bq-html-safe,bq-memo, fullbq-onmodifier system.