1068 lines
27 KiB
Plaintext
1068 lines
27 KiB
Plaintext
```txt
|
||
# SigPro Complete System Documentation
|
||
|
||
## Library Identity
|
||
SigPro is a zero-dependency reactive signal library (~5KB gzipped) for building reactive web applications with fine-grained reactivity, built-in DOM bindings, Web Components, routing, persistent storage, and WebSocket integration.
|
||
|
||
## Core Philosophy
|
||
- Signals are functions, not objects with .value
|
||
- Automatic dependency tracking (no manual declaration)
|
||
- Computed values cache until dependencies change
|
||
- DOM updates batched via microtask queue
|
||
- Everything reactive composes naturally
|
||
|
||
## Complete API Reference
|
||
|
||
### Core Signal: `$(initialValue)`
|
||
The fundamental reactive primitive.
|
||
|
||
**Overload 1: Regular Signal**
|
||
```javascript
|
||
const count = $(0); // Initial value
|
||
count() // Get -> 0
|
||
count(5) // Set -> 5
|
||
count(prev => prev + 1) // Update with function
|
||
count(prev => ({...prev, new: true})) // Object update
|
||
```
|
||
|
||
**Overload 2: Computed Signal**
|
||
```javascript
|
||
const doubled = $(() => count() * 2); // Auto-detects count dependency
|
||
const fullName = $(() => `${firstName()} ${lastName()}`);
|
||
```
|
||
|
||
**Advanced Patterns:**
|
||
```javascript
|
||
// Lazy computed - only recalculates when accessed AND dependencies changed
|
||
const expensive = $(() => {
|
||
console.log('Computing...');
|
||
return data().filter(complexOperation);
|
||
});
|
||
|
||
// Computed with cleanup (rare but possible)
|
||
const withCleanup = $(() => {
|
||
const temp = setup();
|
||
return () => cleanup(temp); // Runs when dependencies change
|
||
});
|
||
|
||
// Circular dependency protection - throws error
|
||
const a = $(() => b());
|
||
const b = $(() => a()); // Error: Circular dependency detected
|
||
```
|
||
|
||
### Effect: `$.effect(fn)`
|
||
Automatic side effect runner with intelligent cleanup.
|
||
|
||
```javascript
|
||
// Basic tracking
|
||
$.effect(() => {
|
||
console.log(`Count is: ${count()}`); // Re-runs when count changes
|
||
});
|
||
|
||
// Cleanup pattern
|
||
$.effect(() => {
|
||
const timer = setInterval(() => {
|
||
console.log('tick', count());
|
||
}, 1000);
|
||
|
||
// Return cleanup - runs BEFORE next effect run OR on stop
|
||
return () => {
|
||
clearInterval(timer);
|
||
console.log('Cleaned up timer');
|
||
};
|
||
});
|
||
|
||
// Nested effects - parent tracks child cleanup
|
||
$.effect(() => {
|
||
const stopChild = $.effect(() => {
|
||
// This child effect is automatically cleaned up
|
||
// when parent re-runs or stops
|
||
});
|
||
});
|
||
|
||
// Effect batching - multiple updates trigger one run
|
||
count(1);
|
||
count(2);
|
||
count(3); // Effect runs once with final value 3
|
||
|
||
// Manual stop
|
||
const stop = $.effect(() => {});
|
||
stop(); // Permanently stops effect, runs cleanup
|
||
|
||
// Effect within effect - parent tracks child
|
||
$.effect(() => {
|
||
const data = fetchData();
|
||
$.effect(() => {
|
||
// This child effect will be auto-cleaned
|
||
// when parent re-runs
|
||
process(data());
|
||
});
|
||
});
|
||
|
||
// Async effects - careful! Only sync dependencies tracked
|
||
$.effect(async () => {
|
||
const id = userId(); // Tracked ✅
|
||
const data = await fetch(`/api/${id}`); // Async
|
||
// DO NOT access signals here - they won't be tracked!
|
||
// Instead, move them before await or use separate effect
|
||
});
|
||
```
|
||
|
||
### DOM Template: `` html`...` ``
|
||
Complete reactive templating system.
|
||
|
||
**Text Interpolation:**
|
||
```javascript
|
||
// Basic
|
||
html`<div>Hello ${name}</div>`
|
||
|
||
// Reactive function
|
||
html`<div>Count: ${() => count()}</div>`
|
||
|
||
// Multiple values
|
||
html`<div>${() => a()} + ${() => b()} = ${() => a() + b()}</div>`
|
||
|
||
// Conditional rendering (falsy values: null/undefined/false render nothing)
|
||
html`<div>${() => isLoading() ? spinner : content}</div>`
|
||
|
||
// List rendering (array of nodes or strings)
|
||
html`<ul>${() => items.map(item => html`<li>${item}</li>`)}</ul>`
|
||
|
||
// Mixed content
|
||
html`<div>${() => [header, content, footer]}</div>`
|
||
|
||
// Fragment handling - auto-wraps multiple root nodes
|
||
html`
|
||
<header>Title</header>
|
||
<main>Content</main>
|
||
<footer>Footer</footer>
|
||
` // Returns DocumentFragment with 3 children
|
||
```
|
||
|
||
**Event Directives - Complete Reference:**
|
||
```javascript
|
||
// Basic
|
||
html`<button @click=${handler}>Click</button>`
|
||
|
||
// Event object access
|
||
html`<input @input=${(e) => value(e.target.value)}>`
|
||
|
||
// All modifiers:
|
||
// prevent - e.preventDefault()
|
||
// stop - e.stopPropagation()
|
||
// once - auto-remove after first fire
|
||
// self - only if e.target === element
|
||
// debounce:ms - debounce handler
|
||
// passive - { passive: true }
|
||
// capture - use capture phase
|
||
|
||
// Examples
|
||
html`<a @click.prevent.stop=${handler} href="#">No navigation, no bubble</a>`
|
||
html`<input @input.debounce:500=${search} placeholder="Search">`
|
||
html`<button @click.once=${oneTime}>Only once</button>`
|
||
html`<div @scroll.passive=${onScroll}>Optimized scroll</div>`
|
||
html`<div @click.capture=${captureHandler}>Capture phase</div>`
|
||
html`<button @click.self=${selfHandler}>Only if button clicked, not children</button>`
|
||
|
||
// Multiple modifiers
|
||
html`<input @keydown.debounce:300.prevent.stop=${handler}>`
|
||
|
||
// Dynamic event handlers
|
||
html`<button @click=${() => show() ? handler1 : handler2}>`
|
||
```
|
||
|
||
**Two-way Binding: `:property`**
|
||
```javascript
|
||
// Text input
|
||
html`<input :value=${userName}>`
|
||
|
||
// Checkbox
|
||
html`<input type="checkbox" :checked=${isAdmin}>`
|
||
|
||
// Radio group
|
||
html`
|
||
<input type="radio" name="size" value="small" :checked=${size === 'small'}>
|
||
<input type="radio" name="size" value="large" :checked=${size === 'large'}>
|
||
`
|
||
|
||
// Select
|
||
html`
|
||
<select :value=${selectedOption}>
|
||
<option value="a">A</option>
|
||
<option value="b">B</option>
|
||
</select>
|
||
`
|
||
|
||
// Textarea
|
||
html`<textarea :value=${content}></textarea>`
|
||
|
||
// Custom elements
|
||
html`<my-input :value=${data}></my-input>`
|
||
|
||
// Updates happen on 'input' for text, 'change' for checkboxes/radios
|
||
// Two-way binding creates two effects automatically
|
||
```
|
||
|
||
**Boolean Attributes: `?attribute`**
|
||
```javascript
|
||
html`<button ?disabled=${isDisabled}>`
|
||
html`<input ?required=${isRequired}>`
|
||
html`<details ?open=${expanded}>`
|
||
html`<option ?selected=${isSelected}>`
|
||
html`<div ?hidden=${!visible}>`
|
||
// Attribute present if truthy, absent if falsy
|
||
```
|
||
|
||
**Property Binding: `.property`**
|
||
```javascript
|
||
html`<div .scrollTop=${scrollPosition}>`
|
||
html`<progress .value=${progress} .max=${100}>`
|
||
html`<video .currentTime=${timestamp} .volume=${volume}>`
|
||
html`<my-element .data=${complexObject}>` // Pass objects directly
|
||
html`<div .classList=${['active', 'visible']}>` // Set classList property
|
||
|
||
// Property binding sets both property AND attribute (unless object/boolean)
|
||
```
|
||
|
||
**Special Cases:**
|
||
```javascript
|
||
// SVG support
|
||
html`<svg width="100" height="100">
|
||
<circle cx="50" cy="50" r=${() => radius()} fill="red"/>
|
||
</svg>`
|
||
|
||
// Self-closing tags
|
||
html`<img src=${imageUrl} alt=${altText}>`
|
||
|
||
// Comments - preserved in template
|
||
html`<!-- Header section --><h1>Title</h1>`
|
||
|
||
// Doctype - only at root
|
||
html`<!DOCTYPE html><html>...</html>`
|
||
|
||
// Template tag
|
||
html`<template>${() => repeatContent()}</template>`
|
||
|
||
// Slot handling
|
||
html`<my-component>${() => slottedContent}</my-component>`
|
||
```
|
||
|
||
### Component System: `$.component(tagName, setupFn, observedAttributes)`
|
||
Full Web Components integration with reactive properties.
|
||
|
||
**Complete Component Lifecycle:**
|
||
```javascript
|
||
$.component('my-counter', (props, ctx) => {
|
||
// 1. Initialization phase
|
||
console.log('Component initializing');
|
||
|
||
// Local state (not exposed as attribute)
|
||
const internalCount = $(0);
|
||
|
||
// Computed from props
|
||
const doubled = $(() => props.count() * 2);
|
||
|
||
// 2. Setup side effects
|
||
const stopEffect = $.effect(() => {
|
||
console.log('Count changed:', props.count());
|
||
});
|
||
|
||
// 3. Register cleanup
|
||
ctx.onUnmount(() => {
|
||
console.log('Cleaning up');
|
||
stopEffect();
|
||
});
|
||
|
||
// 4. Access DOM
|
||
ctx.select('button')?.classList.add('primary');
|
||
|
||
// 5. Handle slots
|
||
const defaultSlot = ctx.slot(); // unnamed slots
|
||
const headerSlot = ctx.slot('header'); // named slots
|
||
|
||
// 6. Emit events
|
||
const handleClick = () => {
|
||
ctx.emit('increment', { value: props.count() });
|
||
ctx.emit('counter-changed', { count: props.count() }, { bubbles: false }); // Custom options
|
||
};
|
||
|
||
// 7. Return template (must return Node or DocumentFragment)
|
||
return html`
|
||
<div class="counter">
|
||
<slot name="header">Default header</slot>
|
||
<button @click=${handleClick}>
|
||
Count: ${() => props.count()}
|
||
</button>
|
||
<slot>Default content</slot>
|
||
</div>
|
||
`;
|
||
}, ['count', 'min', 'max']); // Observed attributes
|
||
|
||
// Usage examples:
|
||
const el = document.createElement('my-counter');
|
||
el.count = 5; // Set property
|
||
el.setAttribute('min', '0'); // Set attribute
|
||
el.min = 10; // Property takes precedence
|
||
console.log(el.count); // Get property -> 10
|
||
console.log(el.getAttribute('count')); // Get attribute -> "10"
|
||
|
||
// Special attribute conversion:
|
||
el.disabled = ''; // true (except for 'value')
|
||
el.disabled = 'false'; // false
|
||
el.value = ''; // "" (empty string preserved)
|
||
el.checked = 'false'; // false
|
||
|
||
// Event listening:
|
||
el.addEventListener('increment', (e) => {
|
||
console.log(e.detail); // { value: x }
|
||
});
|
||
|
||
// Dynamic component creation:
|
||
const container = document.getElementById('app');
|
||
container.innerHTML = '<my-counter count="5"></my-counter>';
|
||
|
||
// Or programmatically:
|
||
const counter = new (customElements.get('my-counter'))();
|
||
counter.count = 5;
|
||
container.appendChild(counter);
|
||
```
|
||
|
||
**Advanced Component Patterns:**
|
||
```javascript
|
||
// Context/provider pattern
|
||
$.component('theme-provider', (props, ctx) => {
|
||
const theme = $(props.theme());
|
||
|
||
// Provide to children via custom event
|
||
ctx.emit('theme-provider', { theme });
|
||
|
||
return html`<slot></slot>`;
|
||
}, ['theme']);
|
||
|
||
$.component('themed-button', (props, ctx) => {
|
||
const theme = $('light');
|
||
|
||
// Consume from parent
|
||
ctx.host.addEventListener('theme-provider', (e) => {
|
||
theme(e.detail.theme);
|
||
});
|
||
|
||
return html`<button class=${() => theme()}>Click</button>`;
|
||
});
|
||
|
||
// Compound components
|
||
$.component('tabs', (props, ctx) => {
|
||
const activeTab = $(0);
|
||
|
||
return html`
|
||
<div class="tabs">
|
||
<div class="tab-headers">
|
||
${() => props.tabs().map((tab, i) => html`
|
||
<button @click=${() => activeTab(i)}>${tab.title}</button>
|
||
`)}
|
||
</div>
|
||
<div class="tab-content">
|
||
${() => props.tabs()[activeTab()].content}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}, ['tabs']);
|
||
|
||
// Form association
|
||
$.component('my-input', (props, ctx) => {
|
||
// Integrate with parent forms
|
||
const form = ctx.host.closest('form');
|
||
if (form) {
|
||
form.addEventListener('reset', () => {
|
||
props.value(props.defaultValue());
|
||
});
|
||
}
|
||
|
||
return html`<input :value=${props.value}>`;
|
||
}, ['value', 'defaultValue']);
|
||
```
|
||
|
||
### Persistent Storage: `$.storage(key, initialValue, storage?)`
|
||
Automatic persistence with change detection.
|
||
|
||
```javascript
|
||
// Basic localStorage
|
||
const settings = $.storage('app-settings', { theme: 'light' });
|
||
settings({ ...settings(), theme: 'dark' }); // Auto-saves
|
||
|
||
// Session storage
|
||
const tempData = $.storage('temp', null, sessionStorage);
|
||
|
||
// Multiple stores
|
||
const userPrefs = $.storage('user-prefs', {});
|
||
const appState = $.storage('app-state', {});
|
||
|
||
// Auto-cleanup
|
||
const cache = $.storage('cache', {});
|
||
cache(null); // Removes from storage
|
||
|
||
// Complex objects - automatic JSON serialization
|
||
const complex = $.storage('complex', {
|
||
date: new Date(),
|
||
regex: /pattern/,
|
||
nested: { array: [1,2,3] }
|
||
});
|
||
|
||
// Storage events - auto sync across tabs
|
||
window.addEventListener('storage', (e) => {
|
||
if (e.key === 'app-settings') {
|
||
// Signal auto-updates on next read
|
||
}
|
||
});
|
||
|
||
// Migration pattern
|
||
const data = $.storage('v2-data', () => {
|
||
// Migration from v1
|
||
const old = localStorage.getItem('v1-data');
|
||
return old ? migrate(JSON.parse(old)) : defaultValue;
|
||
});
|
||
|
||
// Computed from storage
|
||
const config = $(() => {
|
||
const base = settings();
|
||
return { ...base, computed: derive(base) };
|
||
});
|
||
```
|
||
|
||
### Router: `$.router(routes)` and `$.router.go()`
|
||
Hash-based routing with parameter extraction.
|
||
|
||
```javascript
|
||
// Route definitions - comprehensive examples
|
||
const router = $.router([
|
||
// Static routes
|
||
{ path: '/', component: () => html`<home-page></home-page>` },
|
||
{ path: '/about', component: () => html`<about-page></about-page>` },
|
||
|
||
// Parameterized routes
|
||
{
|
||
path: '/users/:id',
|
||
component: (params) => html`<user-profile user-id=${params.id}></user-profile>`
|
||
},
|
||
{
|
||
path: '/posts/:postId/comments/:commentId',
|
||
component: (params) => html`
|
||
<div>
|
||
Post ${params.postId}, Comment ${params.commentId}
|
||
</div>
|
||
`
|
||
},
|
||
|
||
// Regex routes with capture groups
|
||
{
|
||
path: /^\/products\/(?<category>\w+)\/(?<id>\d+)$/,
|
||
component: (params) => html`
|
||
<product-view
|
||
category=${params.category}
|
||
id=${params.id}
|
||
></product-view>
|
||
`
|
||
},
|
||
{
|
||
path: /\/archive\/(\d{4})\/(\d{2})/,
|
||
component: (params) => html`Archive: ${params[0]}-${params[1]}`
|
||
},
|
||
|
||
// Query parameter handling (manual)
|
||
{
|
||
path: '/search',
|
||
component: () => {
|
||
const searchParams = new URLSearchParams(window.location.search);
|
||
return html`<search-results query=${searchParams.get('q')}></search-results>`;
|
||
}
|
||
},
|
||
|
||
// Nested routers
|
||
{
|
||
path: '/dashboard',
|
||
component: () => {
|
||
return $.router([
|
||
{ path: '/', component: dashboardHome },
|
||
{ path: '/settings', component: dashboardSettings }
|
||
]);
|
||
}
|
||
},
|
||
|
||
// Guard pattern
|
||
{
|
||
path: '/admin',
|
||
component: (params) => {
|
||
if (!isAuthenticated()) {
|
||
$.router.go('/login');
|
||
return html`<!-- Empty, redirecting -->`;
|
||
}
|
||
return html`<admin-panel></admin-panel>`;
|
||
}
|
||
},
|
||
|
||
// Catch-all / 404
|
||
{
|
||
path: /.*/,
|
||
component: () => html`<not-found></not-found>`
|
||
}
|
||
]);
|
||
|
||
// Navigation
|
||
$.router.go('/users/123'); // Basic
|
||
$.router.go('users/123'); // Auto-adds leading slash
|
||
$.router.go('/search?q=test'); // Query strings preserved
|
||
|
||
// Advanced navigation
|
||
const navigateWithState = (path, state) => {
|
||
history.replaceState(state, '', `#${path}`);
|
||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||
};
|
||
|
||
// Route change detection
|
||
window.addEventListener('hashchange', () => {
|
||
console.log('Route changed to:', window.location.hash);
|
||
});
|
||
|
||
// Current route signal (if needed)
|
||
const currentRoute = $(() => window.location.hash.replace(/^#/, '') || '/');
|
||
|
||
// Route params as signals
|
||
const routeParams = $(null);
|
||
$.effect(() => {
|
||
const match = currentRoute().match(/\/users\/(\d+)/);
|
||
routeParams(match ? { id: match[1] } : null);
|
||
});
|
||
|
||
// Lazy loading routes
|
||
const router = $.router([
|
||
{
|
||
path: '/heavy',
|
||
component: async (params) => {
|
||
const module = await import('./heavy-page.js');
|
||
return module.default(params);
|
||
}
|
||
}
|
||
]);
|
||
```
|
||
|
||
### Fetch: `$.fetch(url, data, loading?)`
|
||
Simplified data fetching with loading state.
|
||
|
||
```javascript
|
||
// Basic usage
|
||
const result = await $.fetch('/api/users', { id: 123 });
|
||
|
||
// With loading signal
|
||
const isLoading = $(false);
|
||
const data = await $.fetch('/api/search', { query: 'test' }, isLoading);
|
||
console.log(isLoading()); // false after completion
|
||
|
||
// Error handling (returns null on error)
|
||
const result = await $.fetch('/api/fail', {}) || fallbackData;
|
||
|
||
// Integration with signals
|
||
const userData = $(null);
|
||
const loading = $(false);
|
||
|
||
async function loadUser(id) {
|
||
userData(await $.fetch(`/api/users/${id}`, {}, loading));
|
||
}
|
||
|
||
// Reactive fetch with effect
|
||
$.effect(() => {
|
||
const id = currentUserId();
|
||
loadUser(id);
|
||
});
|
||
|
||
// Retry logic wrapper
|
||
async function fetchWithRetry(url, data, retries = 3) {
|
||
for (let i = 0; i < retries; i++) {
|
||
const result = await $.fetch(url, data);
|
||
if (result) return result;
|
||
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Request cancellation
|
||
let abortController = null;
|
||
|
||
$.effect(() => {
|
||
if (abortController) abortController.abort();
|
||
abortController = new AbortController();
|
||
|
||
const id = userId();
|
||
fetch(`/api/user/${id}`, { signal: abortController.signal })
|
||
.then(r => r.json())
|
||
.then(data => userData(data));
|
||
});
|
||
```
|
||
|
||
### WebSocket: `$.ws(url, options)`
|
||
Full-featured WebSocket client with reactive state.
|
||
|
||
```javascript
|
||
// Basic connection
|
||
const ws = $.ws('wss://api.example.com/ws');
|
||
|
||
// With options
|
||
const ws = $.ws('wss://api.example.com/ws', {
|
||
reconnect: true,
|
||
maxReconnect: 10,
|
||
reconnectInterval: 2000 // Base interval for exponential backoff
|
||
});
|
||
|
||
// Reactive state
|
||
$.effect(() => {
|
||
console.log('Status:', ws.status()); // 'connecting', 'connected', 'disconnected', 'error'
|
||
console.log('Messages:', ws.messages()); // Array of received messages
|
||
console.log('Error:', ws.error()); // Last error or null
|
||
});
|
||
|
||
// Sending messages
|
||
ws.send({ type: 'join', room: 'general' });
|
||
ws.send('plain text message');
|
||
|
||
// Auto-reconnect with backoff
|
||
// Attempts: 2s, 4s, 8s, 16s, 32s (until maxReconnect)
|
||
|
||
// Message filtering
|
||
const commands = $(() => ws.messages().filter(m => m.type === 'command'));
|
||
const events = $(() => ws.messages().filter(m => m.type === 'event'));
|
||
|
||
// Send with acknowledgment
|
||
async function sendWithAck(data, timeout = 5000) {
|
||
return new Promise((resolve, reject) => {
|
||
const id = Math.random();
|
||
const message = { ...data, id };
|
||
|
||
const handler = (msg) => {
|
||
if (msg.ack === id) {
|
||
ws.messages.off(handler);
|
||
resolve(msg);
|
||
}
|
||
};
|
||
|
||
// Need message event listener pattern
|
||
const timeoutId = setTimeout(() => {
|
||
ws.messages.off(handler);
|
||
reject(new Error('Ack timeout'));
|
||
}, timeout);
|
||
|
||
// Custom listener would be needed for this pattern
|
||
ws.send(message);
|
||
});
|
||
}
|
||
|
||
// Reconnection handling
|
||
$.effect(() => {
|
||
if (ws.status() === 'connected') {
|
||
console.log('Connected, sending init...');
|
||
ws.send({ type: 'init', token: authToken() });
|
||
}
|
||
});
|
||
|
||
// Binary data
|
||
const ws = $.ws('wss://example.com/binary');
|
||
ws.send(new Blob([data]));
|
||
ws.send(new ArrayBuffer(8));
|
||
|
||
// Heartbeat / ping-pong
|
||
$.effect(() => {
|
||
if (ws.status() !== 'connected') return;
|
||
|
||
const interval = setInterval(() => {
|
||
ws.send({ type: 'ping' });
|
||
}, 30000);
|
||
|
||
return () => clearInterval(interval);
|
||
});
|
||
|
||
// Queue messages while disconnected
|
||
const messageQueue = $([]);
|
||
$.effect(() => {
|
||
if (ws.status() === 'connected') {
|
||
messageQueue().forEach(msg => ws.send(msg));
|
||
messageQueue([]);
|
||
}
|
||
});
|
||
|
||
function sendOrQueue(data) {
|
||
if (ws.status() === 'connected') {
|
||
ws.send(data);
|
||
} else {
|
||
messageQueue([...messageQueue(), data]);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Advanced Patterns & Best Practices
|
||
|
||
**State Management Patterns:**
|
||
```javascript
|
||
// Store pattern
|
||
const createStore = (initial) => {
|
||
const state = $(initial);
|
||
|
||
return {
|
||
state,
|
||
actions: {
|
||
update: (fn) => state(fn(state())),
|
||
reset: () => state(initial)
|
||
}
|
||
};
|
||
};
|
||
|
||
// Selector pattern
|
||
const selectUser = (id) => $(() =>
|
||
users().find(u => u.id === id)
|
||
);
|
||
|
||
// Computed selector
|
||
const selectVisibleTodos = $(() =>
|
||
todos().filter(t =>
|
||
filter() === 'all' ||
|
||
(filter() === 'active' && !t.completed) ||
|
||
(filter() === 'completed' && t.completed)
|
||
)
|
||
);
|
||
|
||
// Action pattern
|
||
const increment = () => count(c => c + 1);
|
||
const addTodo = (text) => todos(t => [...t, { text, completed: false }]);
|
||
const toggleTodo = (id) => todos(t => t.map(todo =>
|
||
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||
));
|
||
```
|
||
|
||
**Performance Optimization:**
|
||
```javascript
|
||
// Memoization
|
||
const expensiveComputation = $(() => {
|
||
console.log('Computing...');
|
||
return bigData().filter(heavyFilter).map(transform);
|
||
});
|
||
|
||
// Batch updates
|
||
count(1);
|
||
count(2);
|
||
count(3); // Single effect run
|
||
|
||
// Manual flush if needed
|
||
import { flushEffectQueue } from 'sigpro';
|
||
flushEffectQueue(); // Force immediate effect processing
|
||
|
||
// Lazy computed - only computes when accessed
|
||
const lazyValue = $(() => {
|
||
if (!cache) cache = expensive();
|
||
return cache;
|
||
});
|
||
|
||
// Effect debouncing
|
||
let timeout;
|
||
$.effect(() => {
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(() => {
|
||
console.log('Debounced:', value());
|
||
}, 100);
|
||
});
|
||
```
|
||
|
||
**Error Boundaries & Recovery:**
|
||
```javascript
|
||
// Effect error handling
|
||
$.effect(() => {
|
||
try {
|
||
riskyOperation();
|
||
} catch (e) {
|
||
errorSignal(e);
|
||
}
|
||
});
|
||
|
||
// Component error boundary
|
||
$.component('error-boundary', (props, ctx) => {
|
||
const error = $(null);
|
||
|
||
const handleError = (e) => {
|
||
error(e.error);
|
||
e.preventDefault();
|
||
};
|
||
|
||
ctx.host.addEventListener('error', handleError);
|
||
ctx.onUnmount(() => ctx.host.removeEventListener('error', handleError));
|
||
|
||
return () => error()
|
||
? html`<error-display error=${error()}></error-display>`
|
||
: html`<slot></slot>`;
|
||
});
|
||
|
||
// Global error handler
|
||
window.addEventListener('error', (e) => {
|
||
console.error('SigPro error:', e.error);
|
||
});
|
||
```
|
||
|
||
**Testing Utilities:**
|
||
```javascript
|
||
// Test helper pattern
|
||
const createTestHarness = () => {
|
||
const effects = [];
|
||
const origEffect = $.effect;
|
||
|
||
$.effect = (fn) => {
|
||
const stop = origEffect(fn);
|
||
effects.push(stop);
|
||
return stop;
|
||
};
|
||
|
||
return {
|
||
cleanup: () => effects.forEach(stop => stop()),
|
||
restore: () => { $.effect = origEffect; }
|
||
};
|
||
};
|
||
|
||
// Async test helper
|
||
const waitForEffect = () => new Promise(r => setTimeout(r, 0));
|
||
|
||
// Usage in tests
|
||
test('counter increments', async () => {
|
||
const count = $(0);
|
||
const calls = [];
|
||
|
||
$.effect(() => {
|
||
calls.push(count());
|
||
});
|
||
|
||
count(1);
|
||
await waitForEffect(); // Wait for effect queue
|
||
expect(calls).toEqual([0, 1]);
|
||
});
|
||
```
|
||
|
||
**TypeScript Integration (conceptual):**
|
||
```typescript
|
||
// Type patterns (even though library is JS)
|
||
type Signal<T> = {
|
||
(): T;
|
||
(value: T | ((prev: T) => T)): void;
|
||
};
|
||
|
||
type Computed<T> = () => T;
|
||
|
||
type Effect = (fn: () => (void | (() => void))) => () => void;
|
||
|
||
// Component props typing
|
||
interface ComponentProps {
|
||
[key: string]: Signal<any>;
|
||
}
|
||
|
||
interface ComponentContext {
|
||
select: (selector: string) => Element | null;
|
||
slot: (name?: string) => Node[];
|
||
emit: (name: string, detail?: any, options?: CustomEventInit) => void;
|
||
host: HTMLElement;
|
||
onUnmount: (fn: () => void) => void;
|
||
}
|
||
```
|
||
|
||
**Migration from Other Frameworks:**
|
||
```javascript
|
||
// From Vue:
|
||
// ref(0) -> $(0)
|
||
// computed -> $(() => value)
|
||
// watch -> $.effect
|
||
// onMounted -> $.effect (runs immediately)
|
||
|
||
// From React:
|
||
// useState -> $ (but get/set combined)
|
||
// useEffect -> $.effect
|
||
// useMemo -> $(() => value)
|
||
// useCallback -> Just use function, dependencies automatic
|
||
|
||
// From Svelte:
|
||
// let count = 0 -> const count = $(0)
|
||
// $: doubled = count * 2 -> const doubled = $(() => count())
|
||
```
|
||
|
||
## Internal Architecture (for deep understanding)
|
||
|
||
```javascript
|
||
// Reactivity graph structure
|
||
{
|
||
activeEffect: Effect | null, // Currently executing effect
|
||
effectQueue: Set<Effect>, // Pending effects
|
||
signal: {
|
||
subscribers: Set<Effect>, // Effects depending on this signal
|
||
value: any, // Current value
|
||
isDirty?: boolean // For computed signals
|
||
},
|
||
effect: {
|
||
dependencies: Set<Set<Effect>>, // Signals this effect depends on
|
||
cleanupHandlers: Set<Function>, // Cleanup functions
|
||
markDirty?: Function // For computed signal effects
|
||
}
|
||
}
|
||
|
||
// Flush mechanism
|
||
// 1. Signal update adds affected effects to queue
|
||
// 2. Schedules microtask flush
|
||
// 3. Effects run in order added
|
||
// 4. Each effect cleans up old dependencies
|
||
// 5. New dependencies tracked during run
|
||
```
|
||
|
||
## Complete Example Application
|
||
|
||
```javascript
|
||
// app.js
|
||
import { $, html, $.component, $.router, $.storage, $.ws } from 'sigpro';
|
||
|
||
// State
|
||
const todos = $.storage('todos', []);
|
||
const filter = $('all');
|
||
const user = $(null);
|
||
const ws = $.ws('wss://api.example.com/todos');
|
||
|
||
// Computed
|
||
const filteredTodos = $(() => {
|
||
const all = todos();
|
||
switch(filter()) {
|
||
case 'active': return all.filter(t => !t.completed);
|
||
case 'completed': return all.filter(t => t.completed);
|
||
default: return all;
|
||
}
|
||
});
|
||
|
||
const stats = $(() => {
|
||
const all = todos();
|
||
return {
|
||
total: all.length,
|
||
active: all.filter(t => !t.completed).length,
|
||
completed: all.filter(t => t.completed).length
|
||
};
|
||
});
|
||
|
||
// Actions
|
||
const addTodo = (text) => {
|
||
const newTodo = {
|
||
id: Date.now(),
|
||
text,
|
||
completed: false,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
todos([...todos(), newTodo]);
|
||
ws.send({ type: 'add', todo: newTodo });
|
||
};
|
||
|
||
const toggleTodo = (id) => {
|
||
todos(todos().map(t =>
|
||
t.id === id ? { ...t, completed: !t.completed } : t
|
||
));
|
||
};
|
||
|
||
const removeTodo = (id) => {
|
||
todos(todos().filter(t => t.id !== id));
|
||
};
|
||
|
||
// Sync with WebSocket
|
||
$.effect(() => {
|
||
ws.messages().forEach(msg => {
|
||
if (msg.type === 'sync') {
|
||
todos(msg.todos);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Components
|
||
$.component('todo-app', () => html`
|
||
<div class="app">
|
||
<header>
|
||
<h1>Todo App</h1>
|
||
<todo-stats stats=${() => stats()}></todo-stats>
|
||
</header>
|
||
|
||
<todo-input @add=${(e) => addTodo(e.detail)}></todo-input>
|
||
|
||
<div class="filters">
|
||
<button @click=${() => filter('all')} ?active=${() => filter() === 'all'}>
|
||
All (${() => stats().total})
|
||
</button>
|
||
<button @click=${() => filter('active')} ?active=${() => filter() === 'active'}>
|
||
Active (${() => stats().active})
|
||
</button>
|
||
<button @click=${() => filter('completed')} ?active=${() => filter() === 'completed'}>
|
||
Completed (${() => stats().completed})
|
||
</button>
|
||
</div>
|
||
|
||
<todo-list
|
||
todos=${() => filteredTodos()}
|
||
@toggle=${(e) => toggleTodo(e.detail)}
|
||
@remove=${(e) => removeTodo(e.detail)}
|
||
></todo-list>
|
||
|
||
${() => ws.status() !== 'connected' ? html`
|
||
<div class="offline-banner">
|
||
Offline - ${() => ws.status()}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`);
|
||
|
||
$.component('todo-input', (props, ctx) => {
|
||
const text = $('');
|
||
|
||
const handleSubmit = () => {
|
||
if (text().trim()) {
|
||
ctx.emit('add', text().trim());
|
||
text('');
|
||
}
|
||
};
|
||
|
||
return html`
|
||
<div class="todo-input">
|
||
<input
|
||
:value=${text}
|
||
@keydown.enter=${handleSubmit}
|
||
placeholder="What needs to be done?"
|
||
>
|
||
<button @click=${handleSubmit}>Add</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
$.component('todo-list', (props) => html`
|
||
<ul class="todo-list">
|
||
${() => props.todos().map(todo => html`
|
||
<li class=${() => todo.completed ? 'completed' : ''}>
|
||
<input
|
||
type="checkbox"
|
||
:checked=${() => todo.completed}
|
||
@change=${() => props.emit('toggle', todo.id)}
|
||
>
|
||
<span>${todo.text}</span>
|
||
<button @click=${() => props.emit('remove', todo.id)}>×</button>
|
||
</li>
|
||
`)}
|
||
</ul>
|
||
`, ['todos']);
|
||
|
||
$.component('todo-stats', (props) => html`
|
||
<div class="stats">
|
||
<span>Total: ${() => props.stats().total}</span>
|
||
<span>Active: ${() => props.stats().active}</span>
|
||
<span>Completed: ${() => props.stats().completed}</span>
|
||
</div>
|
||
`, ['stats']);
|
||
|
||
// Router setup
|
||
const router = $.router([
|
||
{ path: '/', component: () => html`<todo-app></todo-app>` },
|
||
{ path: '/settings', component: () => html`<settings-page></settings-page>` },
|
||
{ path: '/about', component: () => html`<about-page></about-page>` }
|
||
]);
|
||
|
||
// Mount app
|
||
document.body.appendChild(router);
|
||
```
|