woby
Version:
A high-performance framework with fine-grained observable/signal-based reactivity for building rich applications.
589 lines (453 loc) • 19.2 kB
Markdown
# Type Synchronization Between HTML Attributes and Component Props
This document explains how Woby synchronizes types between HTML string-only attributes and typed function component props, enabling seamless two-way binding for custom elements. It includes both a comprehensive approach and a simpler approach to type synchronization.
## Overview
In web components, HTML attributes are always strings, but JavaScript/TypeScript components often need typed values (numbers, booleans, objects). Woby provides mechanisms to automatically convert attribute strings to the appropriate types based on observable declarations.
This document covers both the comprehensive type synchronization mechanism and a simpler approach.
For information about how the `merge` function handles different prop sources, see [Component Defaults](./CUSTOM_ELEMENTS.md#component-defaults-and-two-way-synchronization).
## How It Works
### 1. Typed Observables
The key to type synchronization is using typed observables in your default props:
```typescript
function def() {
return {
value: $(0, { type: 'number' } as const), // Typed as number
disabled: $(false, { type: 'boolean' } as const), // Typed as boolean
label: $('', { type: 'string' } as const) // Typed as string (default)
}
}
```
### 2. Attribute to Prop Conversion
When an HTML attribute changes, Woby automatically converts the string value to the appropriate type based on the observable's type option:
```html
<!-- HTML -->
<my-counter value="5" disabled="true" label="Counter"></my-counter>
```
Conversion process:
- `value="5"` → `value: $(5)` (string "5" converted to number 5)
- `disabled="true"` → `disabled: $(true)` (string "true" converted to boolean true)
- `label="Counter"` → `label: $("Counter")` (string remains string)
### 3. Type Conversion Rules
Woby supports automatic conversion for several built-in types:
| Type Option | HTML Attribute | Converted Value | Notes |
|-------------|----------------|-----------------|-------|
| `'number'` | `"5"` | `5` | Uses `Number(value)` |
| `'boolean'` | `"true"`, `"1"`, `""` | `true` | Special handling |
| `'boolean'` | `"false"`, `"0"`, other | `false` | |
| `'bigint'` | `"123"` | `123n` | Uses `BigInt(value)` |
| `'object'` | `'{"a":1}'` | `{a:1}` | Uses `JSON.parse(value)` |
| `'string'` | `"text"` | `"text"` | No conversion (default) |
| `'undefined'` | `"any"` | `undefined` | Always converts to undefined |
### 4. Special Boolean Handling
Boolean conversion follows HTML standards:
- Truthy: `"true"`, `"1"`, or empty string `""`
- Falsy: `"false"`, `"0"`, or any other value
```html
<!-- All of these set disabled to $(true) -->
<my-element disabled="true"></my-element>
<my-element disabled="1"></my-element>
<my-element disabled=""></my-element>
<!-- All of these set disabled to $(false) -->
<my-element disabled="false"></my-element>
<my-element disabled="0"></my-element>
<my-element disabled="anything"></my-element>
```
## HTML Utility Types
Woby provides a set of HTML utility types that make it easier to work with common HTML attribute patterns. These utilities implement the `ObservableOptions` interface and provide consistent conversion between JavaScript values and HTML attributes.
### HtmlBoolean
Handles boolean values with automatic conversion from HTML string attributes:
```tsx
import { HtmlBoolean } from 'woby'
const def = () => ({
enabled: $(false, HtmlBoolean)
})
```
### HtmlNumber
Handles numeric values with automatic conversion from HTML string attributes:
```tsx
import { HtmlNumber } from 'woby'
const def = () => ({
value: $(0, HtmlNumber)
})
```
### HtmlDate
Handles Date values with ISO string serialization:
```tsx
import { HtmlDate } from 'woby'
const def = () => ({
selectedDate: $(new Date(), HtmlDate)
})
```
### HtmlBigInt
Handles BigInt values with automatic conversion:
```tsx
import { HtmlBigInt } from 'woby'
const def = () => ({
largeNumber: $(BigInt(0), HtmlBigInt)
})
```
### HtmlObject
Handles Object values with JSON serialization:
```tsx
import { HtmlObject } from 'woby'
const def = () => ({
config: $({} as any, HtmlObject)
})
```
### HtmlLength
Handles CSS length values (px, em, rem, %, etc.):
```tsx
import { HtmlLength } from 'woby'
const def = () => ({
width: $('100px', HtmlLength),
height: $('auto', HtmlLength)
})
```
### HtmlBox
Handles CSS box values (margin, padding, border, etc.):
```tsx
import { HtmlBox } from 'woby'
const def = () => ({
margin: $('10px', HtmlBox),
padding: $('5px 10px', HtmlBox)
})
```
### HtmlColor
Handles CSS color values (hex, rgb, etc.):
```tsx
import { HtmlColor } from 'woby'
const def = () => ({
backgroundColor: $('#ffffff', HtmlColor),
textColor: $('rgb(0, 0, 0)', HtmlColor)
})
```
### HtmlStyle
Handles CSS style values (objects and strings):
```tsx
import { HtmlStyle } from 'woby'
const def = () => ({
customStyles: $({} as any, HtmlStyle)
})
```
### Benefits of HTML Utility Types
1. **Type Safety**: Each utility provides proper type conversion between HTML attributes and JavaScript values
2. **Consistency**: All utilities follow the same pattern and behavior
3. **Automatic Serialization**: Complex values are automatically serialized to/from HTML attributes
4. **Error Handling**: Utilities handle edge cases and invalid values gracefully
5. **Empty String Handling**: All utilities treat empty strings as `undefined` for consistent behavior
6. **Equality Checking**: Each utility implements proper equality checking for value comparison
## HTML Attribute Serialization
Custom elements support custom serialization of observable values to and from HTML attributes using the `toHtml` and `fromHtml` options:
### Hiding Properties from HTML Attributes
To prevent a property from appearing in HTML attributes, use the `toHtml` option with a function that returns `undefined`:
```typescript
function def() {
return {
value: $(0, { type: 'number' } as const),
increment: $([() => { value($$(value) + 10) }], { toHtml: o => undefined }), //hide this from html attributes
}
}
```
### Object and Date Serialization
To serialize complex objects and dates to and from HTML attributes, use the `toHtml` and `fromHtml` options:
```typescript
function def() {
return {
obj: $({ nested: { text: 'abc' } }, {
toHtml: o => JSON.stringify(o),
fromHtml: o => JSON.parse(o)
}),
date: $(new Date(), {
toHtml: o => o.toISOString(),
fromHtml: o => new Date(o)
})
}
}
```
These serialization options allow complex JavaScript objects and Date instances to be properly converted to and from HTML attribute strings, enabling two-way synchronization between HTML attributes and component props.
## Handling Different Prop Sources
### Props from HTML Attributes
When props come from HTML attributes, they are always strings and must be converted to the appropriate types:
```html
<my-component count="42" enabled="false" data='{"key":"value"}'></my-component>
```
The conversion process:
1. String values are parsed from HTML attributes
2. Type information from observables determines conversion rules
3. Values are converted and set on observables
### Props from JSX/TSX
When props come from JSX/TSX, they are already typed JavaScript values:
```tsx
const App = () => {
const count = $(42)
const enabled = $(false)
const data = $({key: "value"})
return <MyComponent count={count} enabled={enabled} data={data} />
}
```
In this case:
1. Values are already properly typed
2. No conversion is needed
3. The `merge` function combines them with defaults appropriately
### Inline Parameter Initialization
Using inline parameter initialization can interfere with type synchronization:
```typescript
// ❌ Potential issues with type synchronization
const MyComponent = defaults(def, ({ count = $(0) }: MyComponentProps): JSX.Element => {
// The inline default $(0) may not have type information
// This can cause issues with HTML attribute synchronization
// ...
})
// ✅ Recommended approach
const MyComponent = defaults(def, (props: MyComponentProps): JSX.Element => {
// The merge function properly handles type synchronization
const mergedProps = merge(props, def())
const { count } = mergedProps
// ...
})
```
## Implementation Details
### In Custom Elements
The type conversion happens in the `setObservableValue` function in `custom_element.ts`:
```typescript
const setObservableValue = (obj: any, key: string, value: string) => {
if (isObservable(obj[key])) {
// Cast value according to observable options type
const observable = obj[key] as Observable<any>
const options = (observable[SYMBOL_OBSERVABLE_WRITABLE]).options as ObservableOptions<any> | undefined
const { type, fromHtml } = options ?? {}
if (type) {
switch (type) {
case 'number':
obj[key](fromHtml ? fromHtml(value) : Number(value))
break
case 'boolean':
// Handle various boolean representations
if (fromHtml) {
obj[key](fromHtml(value))
} else {
const lowerValue = value?.toLowerCase()
obj[key](lowerValue === 'true' || lowerValue === '1' || lowerValue === '')
}
break
case 'bigint':
if (fromHtml) {
obj[key](fromHtml(value))
} else {
try {
obj[key](BigInt(value))
} catch (e) {
// If parsing fails, fallback to string
obj[key](value)
}
}
break
case 'object':
if (fromHtml) {
obj[key](fromHtml(value))
} else {
try {
obj[key](JSON.parse(value))
} catch (e) {
// If parsing fails, fallback to string
obj[key](value)
}
}
break
case 'function':
// For function types, we can't really convert from string
// This would typically be handled by the component itself
obj[key](fromHtml ? fromHtml(value) : value)
break
case 'symbol':
// For symbol types, create a symbol from the string
obj[key](fromHtml ? fromHtml(value) : Symbol(value))
break
case 'undefined':
obj[key](fromHtml ? fromHtml(value) : undefined)
break
default:
// For constructor types or other custom types, treat as string
// since HTML attributes are always strings and we can't instantiate
// arbitrary constructors from strings
obj[key](fromHtml ? fromHtml(value) : value)
break
}
} else {
obj[key](fromHtml ? fromHtml(value) : value)
}
} else {
obj[key] = value
}
}
```
### In Component Defaults
When defining defaults, always specify the `type` option for non-string values:
```typescript
// ✅ Correct - with type information
function def() {
return {
count: $(0, { type: 'number' } as const),
enabled: $(true, { type: 'boolean' } as const),
data: $({} as any, { type: 'object' } as const)
}
}
// ❌ Incorrect - no type information
function def() {
return {
count: $(0), // Will be treated as string
enabled: $(true), // Will be treated as string
data: $({}) // Will be treated as string
}
}
```
## Complete Example
Here's a complete example showing type synchronization in action:
```typescript
import { $, defaults, merge, customElement } from 'woby'
import type { Observable } from 'woby'
interface MyComponentProps {
count?: Observable<number>
enabled?: Observable<boolean>
data?: Observable<{[key: string]: any}>
label?: string // Non-observable, treated as string
obj?: Observable<{ nested: { text: string } }>
date?: Observable<Date>
}
function def() {
return {
count: $(0, { type: 'number' } as const),
enabled: $(true, { type: 'boolean' } as const),
data: $({} as any, { type: 'object' } as const),
label: $(''),
obj: $({ nested: { text: 'abc' } }, {
toHtml: o => JSON.stringify(o),
fromHtml: o => JSON.parse(o)
}),
date: $(new Date(), {
toHtml: o => o.toISOString(),
fromHtml: o => new Date(o)
})
}
}
const MyComponent = defaults(def, (props: MyComponentProps): JSX.Element => {
const mergedProps = merge(props, def())
const { count, enabled, data, label, obj, date } = mergedProps
return (
<div>
<p>Count: {count}</p>
<p>Enabled: {enabled ? 'Yes' : 'No'}</p>
<p>Data: {JSON.stringify(data)}</p>
<p>Label: {label}</p>
<p>Object: {() => JSON.stringify($$(obj))}</p>
<p>Date: {() => $$(date).toString()}</p>
</div>
)
})
customElement('my-component', MyComponent)
```
Usage:
```html
<!-- HTML usage with automatic type conversion -->
<my-component
count="42"
enabled="false"
data='{"key":"value"}'
label="My Component"
obj='{"nested":{"text":"xyz"}}'
date="2023-01-01T00:00:00.000Z">
</my-component>
```
In this example:
- `count="42"` becomes `count: $(42)` (number)
- `enabled="false"` becomes `enabled: $(false)` (boolean)
- `data='{"key":"value"}'` becomes `data: $({key: "value"})` (object)
- `label="My Component"` becomes `label: "My Component"` (string)
- `obj='{"nested":{"text":"xyz"}}'` becomes `obj: $({nested: {text: "xyz"}})` (object with custom serialization)
- `date="2023-01-01T00:00:00.000Z"` becomes `date: $(new Date("2023-01-01T00:00:00.000Z"))` (date with custom serialization)
## Best Practices
1. **Always Specify Types**: For non-string props, always use the `type` option in your defaults
2. **Use `as const`**: Cast type options to `as const` for better TypeScript inference
3. **Handle Edge Cases**: Consider what should happen with invalid values (e.g., `"not-a-number"` for a number type)
4. **Test Both Directions**: Verify that both HTML attribute changes and prop changes work correctly
5. **Use `merge(props, def())`**: Always use the merge pattern for proper handling of different prop sources
6. **Use `toHtml` and `fromHtml`**: For complex objects and dates, use these options for proper serialization
7. **Hide Functions from HTML**: Use `toHtml: () => undefined` to prevent functions from appearing in HTML attributes
8. **Store Functions in Array Notation**: Use `$([() => { /* function body */ }])` to store functions in observables for custom elements
## Simple Type Synchronization Approach
For those who prefer a more straightforward approach to type synchronization, Woby also provides a simpler mechanism.
### Basic Implementation
The simplest way to achieve type synchronization is by declaring observables with explicit type information using the `{ type: '...' }` option:
```typescript
const def = () => ({
// Number type
count: $(0, { type: 'number' } as const),
// Boolean type
enabled: $(true, { type: 'boolean' } as const),
// String type (default)
label: $('', { type: 'string' } as const),
// Object type
config: $({} as MyConfig, { type: 'object' } as const)
})
```
### How It Works
1. **Declaration**: When defining observables, specify the expected type using the `type` option
2. **Attribute Binding**: HTML attributes automatically bind to these typed observables
3. **Automatic Conversion**: The framework converts string attributes to the declared types
#### Example Component
```typescript
import { $, component, defaults } from 'woby'
const def = () => ({
value: $(0, { type: 'number' } as const),
disabled: $(false, { type: 'boolean' } as const),
label: $('', { type: 'string' } as const)
})
const Counter = (props: ReturnType<typeof def>) => {
const { value, disabled, label } = defaults(def, props)
// value is typed as number
// disabled is typed as boolean
// label is typed as string
return (
<div>
<span>{label}</span>
<button disabled={disabled} onclick={() => value(prev => prev + 1)}>
Count: {value}
</button>
</div>
)
}
component('my-counter', Counter, def)
```
#### HTML Usage
```html
<!-- The framework automatically converts these string attributes -->
<my-counter value="5" disabled="false" label="My Counter"></my-counter>
```
In this example:
- `"5"` (string) gets converted to `5` (number)
- `"false"` (string) gets converted to `false` (boolean)
- `"My Counter"` remains as string
### Supported Types
The simple type synchronization supports these types:
| Type | Conversion Rule | Example |
|----------|--------------------------------------------|-------------------|
| number | `Number(attributeValue)` | "42" → 42 |
| boolean | `"true"/"1"/""` → `true`, otherwise false | "false" → false |
| string | No conversion (default) | "text" → "text" |
| object | `JSON.parse(attributeValue)` | '{"a":1}' → {a:1} |
### Key Benefits
1. **Automatic**: No manual parsing required in components
2. **Type-safe**: TypeScript knows the correct types
3. **Declarative**: Type information is declared with the observable
4. **Bidirectional**: Works for both HTML attributes and programmatic updates
### Best Practices
1. Always use `as const` with the type option for proper TypeScript inference:
```typescript
$(0, { type: 'number' } as const) // ✅ Good
$(0, { type: 'number' }) // ❌ Less precise typing
```
2. Declare all attributes that should participate in type synchronization with explicit types
3. Use sensible defaults that match your expected types
This simple approach eliminates the need for manual attribute parsing while maintaining full TypeScript type safety.
## Limitations
1. **Constructor Types**: Custom constructor types cannot be automatically instantiated from strings
2. **Function Types**: Functions cannot be meaningfully created from strings