diff --git a/src/router/links.ts b/src/router/links.ts index 32d1a142..667003d2 100644 --- a/src/router/links.ts +++ b/src/router/links.ts @@ -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); diff --git a/src/store/create-store.ts b/src/store/create-store.ts index 8856819e..466dff25 100644 --- a/src/store/create-store.ts +++ b/src/store/create-store.ts @@ -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'; @@ -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. diff --git a/src/view/directives/on.ts b/src/view/directives/on.ts index f7bc024b..ee9229c7 100644 --- a/src/view/directives/on.ts +++ b/src/view/directives/on.ts @@ -1,4 +1,4 @@ -import { evaluate, evaluateRaw } from '../evaluate'; +import { evaluateRaw } from '../evaluate'; import type { DirectiveHandler } from '../types'; /** @@ -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); diff --git a/tests/component.test.ts b/tests/component.test.ts index 89937e23..31c4c819 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -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', () => { @@ -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`
Test
`, + }); + + 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`
Test
`, + }); + + // 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`
Test
`, + }); + + 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`
Shadow Content
`, + }); + + 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`
Count: ${props.count}
`; + }, + }); + + 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`
Safe text
`, + }); + + 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('