Skip to content
Merged
12 changes: 11 additions & 1 deletion src/router/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ export const interceptLinks = (container: Element = document.body): (() => void)
if (anchor.hasAttribute('download')) return;
if (anchor.origin !== window.location.origin) return; // External link

const path = anchor.pathname + anchor.search + anchor.hash;
// Detect hash-routing mode: links written as href="#/page"
// In this case, anchor.hash contains the route path
let path: string;
if (anchor.hash && anchor.hash.startsWith('#/')) {
// Hash-routing mode: extract path from the hash
// e.g., href="#/page?foo=bar" → path = "/page?foo=bar"
path = anchor.hash.slice(1); // Remove leading #
} else {
// History mode or regular anchor: use pathname + search + hash
path = anchor.pathname + anchor.search + anchor.hash;
}

e.preventDefault();
navigate(path);
Expand Down
17 changes: 15 additions & 2 deletions src/store/create-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
* Store creation logic.
*/

import { batch, computed, signal, type ReadonlySignal, type Signal } from '../reactive/index';
import {
batch,
computed,
signal,
untrack,
type ReadonlySignal,
type Signal,
} from '../reactive/index';
import { notifyDevtoolsStateChange, registerDevtoolsStore } from './devtools';
import { applyPlugins } from './plugins';
import { getStore, hasStore, registerStore } from './registry';
Expand Down Expand Up @@ -56,9 +63,15 @@ export const createStore = <
* trigger reactive updates. This differs from frameworks like Pinia that
* use deep reactivity. To update nested state, replace the entire object.
*
* Uses `untrack()` to prevent accidental dependency tracking when called
* from within reactive contexts (e.g., `effect()` or `computed()`).
*
* @internal
*/
const getCurrentState = (): S => ({ ...stateProxy });
const getCurrentState = (): S =>
untrack(() => {
return { ...stateProxy };
});

/**
* Notifies subscribers of state changes.
Expand Down
7 changes: 4 additions & 3 deletions src/view/directives/on.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { evaluate, evaluateRaw } from '../evaluate';
import { evaluateRaw } from '../evaluate';
import type { DirectiveHandler } from '../types';

/**
Expand All @@ -24,8 +24,9 @@ export const handleOn = (eventName: string): DirectiveHandler => {
}
}

// Otherwise evaluate as expression (e.g., "handleClick($event)" or "count++")
evaluate(expression, eventContext);
// Otherwise evaluate as expression using evaluateRaw to allow signal mutations
// (e.g., "count.value++" or "handleClick($event)")
evaluateRaw(expression, eventContext);
};

el.addEventListener(eventName, handler);
Expand Down
166 changes: 165 additions & 1 deletion tests/component.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { component, html } from '../src/component/index';
import { component, defineComponent, html } from '../src/component/index';

describe('component/html', () => {
it('creates HTML from template literal', () => {
Expand Down Expand Up @@ -598,3 +598,167 @@ describe('component/component', () => {
el.remove();
});
});

describe('component/defineComponent', () => {
it('returns an HTMLElement subclass', () => {
const tagName = `test-define-class-${Date.now()}`;
const ElementClass = defineComponent(tagName, {
props: {},
render: () => html`<div>Test</div>`,
});

expect(ElementClass).toBeDefined();
expect(typeof ElementClass).toBe('function');
expect(ElementClass.prototype instanceof HTMLElement).toBe(true);
});

it('returned class can be registered with custom tag name', () => {
const originalTagName = `test-define-original-${Date.now()}`;
const customTagName = `test-define-custom-${Date.now()}`;

const ElementClass = defineComponent(originalTagName, {
props: {},
render: () => html`<div>Test</div>`,
});

// Register with a different tag name
customElements.define(customTagName, ElementClass);

expect(customElements.get(customTagName)).toBe(ElementClass);

const el = document.createElement(customTagName);
document.body.appendChild(el);

expect(el.shadowRoot).toBeDefined();
expect(el.shadowRoot?.innerHTML).toContain('Test');

el.remove();
});

it('returned class has correct observedAttributes', () => {
const tagName = `test-define-observed-${Date.now()}`;
const ElementClass = defineComponent(tagName, {
props: {
name: { type: String, required: true },
count: { type: Number, default: 0 },
active: { type: Boolean, default: false },
},
render: () => html`<div>Test</div>`,
});

const observedAttrs = (ElementClass as typeof HTMLElement & { observedAttributes: string[] }).observedAttributes;
expect(observedAttrs).toBeDefined();
expect(observedAttrs).toContain('name');
expect(observedAttrs).toContain('count');
expect(observedAttrs).toContain('active');
});

it('instances have shadow DOM', () => {
const tagName = `test-define-shadow-${Date.now()}`;
const ElementClass = defineComponent(tagName, {
props: {},
render: () => html`<div>Shadow Content</div>`,
});

customElements.define(tagName, ElementClass);
const el = document.createElement(tagName);
document.body.appendChild(el);

expect(el.shadowRoot).toBeDefined();
expect(el.shadowRoot?.mode).toBe('open');
expect(el.shadowRoot?.innerHTML).toContain('Shadow Content');

el.remove();
});

it('attributeChangedCallback triggers re-render', () => {
const tagName = `test-define-attr-change-${Date.now()}`;
let renderCount = 0;

const ElementClass = defineComponent<{ count: number }>(tagName, {
props: {
count: { type: Number, default: 0 },
},
render: ({ props }) => {
renderCount++;
return html`<div>Count: ${props.count}</div>`;
},
});

customElements.define(tagName, ElementClass);
const el = document.createElement(tagName);
document.body.appendChild(el);

expect(renderCount).toBe(1);
expect(el.shadowRoot?.innerHTML).toContain('Count: 0');

// Trigger attribute change
el.setAttribute('count', '42');

expect(renderCount).toBe(2);
expect(el.shadowRoot?.innerHTML).toContain('Count: 42');

el.remove();
});

it('instances sanitize rendered markup for security', () => {
const tagName = `test-define-sanitize-${Date.now()}`;
const ElementClass = defineComponent(tagName, {
props: {},
render: () => html`<div><script>alert('xss')</script>Safe text</div>`,
});

customElements.define(tagName, ElementClass);
const el = document.createElement(tagName);
document.body.appendChild(el);

const shadowHTML = el.shadowRoot?.innerHTML ?? '';
// Script tags should be stripped by sanitizeHtml
expect(shadowHTML).not.toContain('<script>');
expect(shadowHTML).not.toContain('alert');
expect(shadowHTML).toContain('Safe text');

el.remove();
});

it('instances apply styles correctly', () => {
const tagName = `test-define-styles-${Date.now()}`;
const ElementClass = defineComponent(tagName, {
props: {},
styles: '.test { color: red; }',
render: () => html`<div class="test">Styled</div>`,
});

customElements.define(tagName, ElementClass);
const el = document.createElement(tagName);
document.body.appendChild(el);

const styleTag = el.shadowRoot?.querySelector('style');
expect(styleTag).toBeDefined();
expect(styleTag?.textContent).toContain('color: red');

el.remove();
});

it('can be used to test component in isolation', () => {
// This pattern is useful for testing without polluting global registry
const ElementClass = defineComponent('test-isolated', {
props: {
value: { type: String, default: 'default' },
},
render: ({ props }) => html`<span>${props.value}</span>`,
});

// Use a unique tag for testing
const testTag = `test-isolated-${Date.now()}`;
customElements.define(testTag, ElementClass);

const el = document.createElement(testTag);
el.setAttribute('value', 'custom');
document.body.appendChild(el);

expect(el.shadowRoot?.innerHTML).toContain('custom');

el.remove();
});
});
36 changes: 36 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,42 @@ describe('Router', () => {
await new Promise((r) => setTimeout(r, 0));
expect(currentRoute.value.path).toBe('/'); // Should not navigate after cleanup
});

it('should handle hash-routing links with href="#/route"', async () => {
router = createRouter({
routes: [
{ path: '/', component: () => null },
{ path: '/some-route', component: () => null },
{ path: '/page', component: () => null },
],
hash: true,
});

// Test hash-routing link with path only
container.innerHTML = '<a href="#/some-route">Hash Link</a>';
const anchor = container.querySelector('a')!;

const cleanup = interceptLinks(container);

const event = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor.dispatchEvent(event);

await new Promise((r) => setTimeout(r, 0));
expect(currentRoute.value.path).toBe('/some-route');

// Test hash-routing link with query parameters
container.innerHTML = '<a href="#/page?foo=bar">Hash Link with Query</a>';
const anchor2 = container.querySelector('a')!;

const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor2.dispatchEvent(event2);

await new Promise((r) => setTimeout(r, 0));
expect(currentRoute.value.path).toBe('/page');
expect(currentRoute.value.query).toEqual({ foo: 'bar' });

cleanup();
});
});

// ============================================================================
Expand Down
34 changes: 34 additions & 0 deletions tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,40 @@ describe('Store', () => {

expect(states).toEqual([1]);
});

it('should not create reactive dependencies when $state is accessed inside effect', () => {
const store = createStore({
id: 'counter',
state: () => ({ count: 0 }),
});

let effectRunCount = 0;
const stateSnapshots: Array<{ count: number }> = [];

// Access $state inside an effect - this calls getCurrentState()
// Without untrack(), this would register the effect as dependent on all store signals
effect(() => {
effectRunCount++;

// Reading $state calls getCurrentState() which is wrapped in untrack()
// This should NOT create a dependency on store signals
const snapshot = store.$state;
stateSnapshots.push(snapshot);
});

// Effect should run once on creation
expect(effectRunCount).toBe(1);
expect(stateSnapshots).toHaveLength(1);
expect(stateSnapshots[0]).toEqual({ count: 0 });

// Mutate store state
store.count = 5;

// Effect should NOT re-run due to store changes (no reactive dependency created)
// because getCurrentState() is wrapped in untrack()
expect(effectRunCount).toBe(1);
expect(stateSnapshots).toHaveLength(1); // No new snapshots captured
});
});

describe('$state', () => {
Expand Down
43 changes: 43 additions & 0 deletions tests/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,49 @@ describe('View', () => {

expect(eventType).toBe('click');
});

it('should support signal mutations in event expressions', () => {
container.innerHTML = `
<div>
<span bq-text="count"></span>
<button id="increment" bq-on:click="count.value++">Increment</button>
<button id="decrement" bq-on:click="count.value--">Decrement</button>
<button id="add-five" bq-on:click="count.value += 5">Add 5</button>
</div>
`;
const count = signal(0);

view = mount(container, { count });

const span = container.querySelector('span')!;
const incrementBtn = container.querySelector('#increment')! as HTMLButtonElement;
const decrementBtn = container.querySelector('#decrement')! as HTMLButtonElement;
const addFiveBtn = container.querySelector('#add-five')! as HTMLButtonElement;

// Initial value
expect(span.textContent).toBe('0');
expect(count.value).toBe(0);

// Test increment
incrementBtn.click();
expect(count.value).toBe(1);
expect(span.textContent).toBe('1');

// Test increment again
incrementBtn.click();
expect(count.value).toBe(2);
expect(span.textContent).toBe('2');

// Test decrement
decrementBtn.click();
expect(count.value).toBe(1);
expect(span.textContent).toBe('1');

// Test compound assignment
addFiveBtn.click();
expect(count.value).toBe(6);
expect(span.textContent).toBe('6');
});
});

describe('bq-for', () => {
Expand Down
Loading