Skip to content

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.

ts
import { mount } from '@bquery/bquery/view';
import { signal, computed } from '@bquery/bquery/reactive';

Basic Usage

html
<div id="app">
  <input bq-model="name" />
  <p bq-text="greeting"></p>
</div>
ts
const name = signal('World');
const greeting = computed(() => `Hello, ${name.value}!`);

const view = mount('#app', { name, greeting });

Directives

bq-text

Binds text content:

html
<p bq-text="message"></p>
<span bq-text="count + ' items'"></span>

bq-html

Binds innerHTML (sanitized by default):

html
<div bq-html="richContent"></div>

Sanitization can be disabled (use with caution):

ts
mount('#app', { content }, { sanitize: false });

bq-if

Conditional rendering (removes/inserts element):

html
<div bq-if="isLoggedIn">Welcome back!</div>
<div bq-if="!isLoggedIn">Please log in.</div>

bq-show

Toggle visibility via CSS display:

html
<div bq-show="isVisible">This toggles display: none</div>

bq-class

Dynamic class binding:

html
<!-- 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:

html
<!-- 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:

html
<!-- 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.

html
<!-- 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.

ts
const serverError = signal('');
mount('#app', { form, serverError });

bq-aria

Bind ARIA attributes from an object literal or an expression that returns an object.

html
<!-- 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.

ts
const navAria = signal({
  label: 'Primary navigation',
  current: 'page',
});

mount('#app', { isOpen, panelId, buttonLabel, navAria });

bq-bind:attr

Bind any attribute:

html
<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:

html
<input bq-bind:required="isRequired" />
<!-- If isRequired is false, the 'required' attribute is removed -->

bq-on:event

Event binding:

html
<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:

html
<button bq-on:click="handleClick($event)">Click</button> <input bq-on:keydown="onKey($event)" />

Access the element with $el:

html
<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:

html
<div bq-cloak>
  <p bq-text="message"></p>
</div>
css
[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:

html
<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:

html
<!-- 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.

html
<!-- 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)
ts
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:

html
<input bq-ref="inputEl" />
ts
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:

ts
const view = mount('#app', {
  name: signal('World'),
  greeting: computed(() => `Hello, ${name.value}!`),
  handleClick: () => console.log('Clicked!'),
});

With options:

ts
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:

ts
type View = {
  el: Element; // The root element
  context: BindingContext; // The binding context
  update: (newContext: Partial<BindingContext>) => void;
  destroy: () => void; // Cleanup all effects
};

Updating Context

ts
const view = mount('#app', { count: signal(0) });

// Add new values to context
view.update({
  newValue: signal('hello'),
});

Cleanup

Always destroy views when done:

ts
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:

ts
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:

ts
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:

html
<div id="app">
  <p x-text="message"></p>
  <div x-if="showDetails">Details</div>
</div>
ts
mount('#app', context, { prefix: 'x' });

Expressions

Directives accept JavaScript expressions:

html
<!-- 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:

ts
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

ts
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:

ts
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:

ts
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:

html
<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:

html
<!-- In your HTML file or template literal -->
<div bq-if="isLoggedIn" bq-text="username"></div>

DO sanitize context values that come from users:

ts
const userInput = signal(sanitizeHtml(untrustedInput));
mount('#app', { userInput });

Unsafe Usage

NEVER use expressions derived from user input:

ts
// 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:

ts
// 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 execute

If You Need User-Generated Templates

If your application requires loading templates from external sources:

  1. Validate attribute values before mounting - strip or escape bq-* attributes
  2. Use an allowlist of permitted expressions
  3. Consider a sandboxed approach using iframes for truly untrusted content
  4. Use static bindings with sanitized values instead of dynamic expressions:
ts
// 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)

DirectivePurpose
bq-textSet element text content from an expression.
bq-html / bq-html-safeRender HTML — bq-html-safe sanitizes before insertion.
bq-modelTwo-way binding for inputs, selects, textareas, checkboxes.
bq-show / bq-ifToggle visibility or DOM mounting on a condition.
bq-forRender 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-styleReactive class / style objects.
bq-attr / bq-ariaReactive attribute / ARIA bag.
bq-onceRun the binding exactly once, then untrack.
bq-initExecute an expression on mount only.
bq-preSkip directive parsing in this subtree (preserve literal source).
bq-cloakHide until the view is mounted to prevent FOUC.
bq-memoMemoize a subtree by a reactive key.
bq-errorPer-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-html sanitizes when the sanitize mount option is true (the default); bq-html-safe sanitizes unconditionally — prefer it for untrusted content to guard against accidental sanitize: false.
  • bq-for requires stable keys for reordering — supply :key="item.id" (or bq-key="item.id") to avoid re-creating subtrees.
  • bq-model on <select multiple> always reads/writes an array, not a single string.
  • Call destroy() on the View returned by mount() when removing dynamic views — leaked bindings keep signals subscribed.

Performance notes

  • Wrap expensive subtrees in bq-memo keyed on their inputs.
  • Use bq-once for 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 with screen, within(), fireEvent.*, and tick() from @bquery/bquery/testing to drive views in bun:test.
  • Combine with mockSignal() to assert reactivity at the directive level.
  • 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.0parseDirective, ParsedDirective, new directives bq-once, bq-init, bq-pre, bq-cloak, bq-html-safe, bq-memo, full bq-on modifier system.

Released under the MIT License.