Data Types
Gooey has a set of core authoritative data types for application data:
- A model is created via
model()
and behaves like a plain old JavaScript Object holding a fixed set of keys - A dict is created via
dict()
and behaves like a plain old JavaScript Map - A collection is created via
collection()
and behaves just like a plain old JavaScript Array with a few additional methods - A field is created via
field()
and wraps a single value with.get()
&.set()
accessor methods
There are two additional derived data types:
- A calculation is created via
calc()
and is a special function which takes no arguments - A collection view is created via
*View()
collection/view methods and behaves like a read-only JavaScript Array with a few additional methods
Reads and writes to all of these data types are monitored by Gooey. When any changes are observed, Gooey schedules a recalculation of any derived data types that depend on those changes.
model()
Create a model object, which is initialized from the provided target
.
function model<T extends {}>(target: T, debugName?: string | undefined): Model<T>
Avoid mutating target
after creating a model. The optional debugName
is only used for diagnostic purposes.
Keys added / removed to the model are not tracked, only the fields present in the value passed to model()
are tracked. If you want a dynamic map of key → value, use dict()
.
dict()
Create a dict object, which is initially empty or populated from the provided entries
.
interface Dict<TKey, TValue> {
clear(): void;
delete(key: TKey): void;
forEach(fn: (value: TValue, key: TKey) => void): void;
get(key: TKey): TValue;
has(key: TKey): boolean;
set(key: TKey, value: TValue): this;
entries(debugName?: string): View<[TKey, TValue]>;
keys(debugName?: string): View<TKey>;
values(debugName?: string): View<TValue>;
subscribe(handler: (events: DictEvent<TKey, TValue>[]) => void)): void;
}
function dict<TKey, TValue>(entries?: [TKey, TValue][], debugName?: string | undefined): Dict<TKey, TValue>
The optional debugName
is only used for diagnostic purposes.
collection()
Create a collection initially populated with items
.
export interface Collection<T> {
// Array-like methods:
splice(start: number, deleteCount?: number | undefined): T[];
splice(start: number, deleteCount: number, ...items: T[]): T[];
push(...items: T[]): number;
pop(): T | undefined;
shift(): T | undefined;
unshift(...items: T[]): number;
sort(cmp?: ((a: T, b: T) => number) | undefined): this;
reverse(): this;
// Collection-specific methods
reject: (pred: (val: T) => boolean) => T[];
moveSlice: (fromIndex: number, count: number, toIndex: number) => void;
mapView: <V>(
fn: (val: T) => V,
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
filterView: (
fn: (val: T) => boolean,
debugName?: string | undefined
) => View<T, ArrayEvent<T>>;
flatMapView: <V>(
fn: (val: T) => V[],
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}
function collection<T>(items: T[], debugName?: string | undefined): Collection<T>
Avoid mutating items
after creating a collection. The optional debugName
is only used for diagnostic purposes.
Note: .mapView()
, .filterView()
, and .flatMapView()
return View
types, which are like read-only collections.
Note: Additional methods that live on Array.prototype
that may not be listed in the above are present on Collection
objects, and will behave correctly.
collection views
Views are read-only collections that contain transformed/filtered values. They’re produced via the .mapView()
, .filterView()
, and .flatMapView()
methods, which live both on Collection
and View
objects.
export interface View<T> {
// Disabled Array-like methods:
splice(start: number, deleteCount?: number | undefined): never;
splice(start: number, deleteCount: number, ...items: T[]): never;
push(...items: T[]): never;
pop(): never;
shift(): never;
unshift(...items: T[]): never;
sort(cmp?: ((a: T, b: T) => number) | undefined): never;
reverse(): never;
// View-specific methods
mapView: <V>(
fn: (val: T) => V,
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
filterView: (
fn: (val: T) => boolean,
debugName?: string | undefined
) => View<T, ArrayEvent<T>>;
flatMapView: <V>(
fn: (val: T) => V[],
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}
When a view’s source collection has changed, a task to update the view is scheduled. This occurs asynchronously, so if you mutate a Collection
and immediately attempt to read a View
, the anticipated updates will not occur.
Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush()
to ensure the global dependency graph is processed and all updates have been performed.
field()
Create a field that has an initial value. The optional debugName
is only used for diagnostic purposes.
type FieldSubscriber<T> = (error: undefined, val: T) => void;
interface Field<T> {
get: () => T;
set: (val: T) => void;
subscribe: (observer: FieldSubscriber<T>) => () => void;
retain: () => void;
release: () => void;
}
function field<T>(value: T, debugName?: string): Field<T>
Fields are single values that Gooey tracks reads from & writes to. It’s often more convenient to lump multiple field-like values into an object with model()
, but sometimes just a single piece of state will do.
The optional debugName
is only used for diagnostic purposes.
calc()
Create a calculation. Calculations are functions that take no arguments and return a value.
type CalcUnsubscribe = () => void;
interface Calculation<T> {
(): T;
onError: (handler: (error: Error) => T) => this;
setCmp: (eq: (a: T, b: T) => boolean) => this;
subscribe: (handler: (value: T) => void) => CalcUnsubscribe;
subscribeWithError: (handler: (...args: [error: undefined, value: T] | [error: Error, value: undefined]) => void) => CalcUnsubscribe;
}
function calc<T>(fn: () => T, debugName?: string | undefined): Calculation<T>
Calculations are automatically memoized per their dependencies, so multiple invocations will return the result of the first invocation, until the calculation is recalculated. When any of the calculation’s dependencies have changed, a task to recalculate the calculation is scheduled. This occurs asynchronously, so if you mutate a dependency and immediately attempt to get the result of a calculation, you will receive a cached result.
Calculation dependencies are determined on a per-invocation basis, so if a calculation does not read a potentially dependent value (by returning early or short-circuiting), it will not be recalculated if that unaccessed dependency changes.
Note: If you need to ensure a calculation is accurately up-to-date prior to calling it, you may call flush()
to ensure the global dependency graph is processed synchronously and all updates have been performed.
Model Helpers
In addition to functioning just like JavaScript objects, there are a few helper utilities that can be used with models.
model.field()
Obtain a Field
object representing one of the values in a model.
model.field<T extends {}, K extends keyof T>(sourceModel: Model<T>, field: K): Field<T[K]>
Behavior is not defined if the provided key is not present in the model.
model.subscribe()
Subscribe to a stream of events when modifications are made to the target model.
export enum ModelEventType {
SET = 'set',
}
export type ModelEvent =
{ type: ModelEventType.SET; prop: string; value: any }
model.subscribe<T extends {}>(targetModel: Model<T>, handler: (event: ModelEvent[]) => void, debugName?: string | undefined): () => void
When targetModel
is changed, a task to notify subscribers is scheduled. This task to call handler
is performed asynchronously.
The returned function can be called to unsubscribe.
The kinds of modifications are:
ModelEventType.SET
- an existing field is updated on the model
Map Methods
Maps behave like built-in JavaScript Map objects, with keys/values/entries methods augmented to return View
objects instead of iterators.
To avoid conflicting with the built-in Map<TKey, TValue>
type, the map type is exported as Dict<TKey, TValue>
.
.get()
Read a value associated with a key.
interface Dict<TKey, TValue> {
get(key: TKey): TValue | undefined;
}
If a the provided key
is not present in the map, undefined
is returned, and the read is still considered to be tracked as a dependency from the map.
.has()
Check if a value is associated with a key.
interface Dict<TKey, TValue> {
has(key: TKey): boolean;
}
If a the provided key
is not present in the map, the read is still considered to be tracked as a dependency from the map.
.set()
Associate a value with a key.
interface Dict<TKey, TValue> {
set(key: TKey, value: TValue): this;
}
For convenience, the map instance is returned so this operation may be chained.
.delete()
Remove a key.
interface Dict<TKey, TValue> {
delete(key: TKey): void;
}
If the key
is not present in the map, nothing occurs.
.clear()
Empty a map.
interface Dict<TKey, TValue> {
clear(): void;
}
All keys and values are removed from the map.
.forEach()
Iterate over all values and keys.
interface Dict<TKey, TValue> {
forEach(iter: (value: TValue, key: TKey) => void): void;
}
Note: this is considered to be a read on the set of keys that live in the map. Iterating over a map’s keys in a calculation will cause the calculation to be recalculated if any key is changed, added, or deleted.
.entries()
Get a view holding the map’s entries.
interface Dict<TKey, TValue> {
entries(): View<[TKey, TValue]>;
}
Unlike JavaScript’s Map’s .entries
method, this method produce a View
that reflects the entries in the tracked map as the tracked map is updated over time.
Sort order is not defined.
.keys()
Get a view holding the map’s keys.
interface Dict<TKey, TValue> {
keys(): View<TKey>;
}
Unlike JavaScript’s Map’s .keys
method, this method produce a View
that reflects the keys in the tracked map as the tracked map is updated over time.
Sort order is not defined.
.values()
Get a view holding the map’s keys.
interface Dict<TKey, TValue> {
values(): View<TValue>;
}
Unlike JavaScript’s Map’s .values
method, this method produce a View
that reflects the values in the tracked map as the tracked map is updated over time.
Sort order is not defined.
.subscribe()
Subscribe to changes as the map is processed.
enum DictEventType {
ADD = 'add',
SET = 'set',
DEL = 'del',
}
type DictEvent<K, V> =
| { type: DictEventType.ADD; prop: K; value: V }
| { type: DictEventType.SET; prop: K; value: V }
| { type: DictEventType.DEL; prop: K; value?: V };
interface Dict<TKey, TValue> {
subscribe(handler: (events: DictEvent<TKey, TValue>[]) => void): () => void
}
When the map is changed, a task to notify subscribers is scheduled. This task to call handler
is performed asynchronously.
The returned function can be called to unsubscribe.
The kinds of modifications are:
DictEventType.ADD
- a new key is added to the mapDictEventType.SET
- an existing key is updated on the mapDictEventType.DEL
- an existing key is removed from the map
.field()
Get a Field representing a key in the map.
interface Dict<TKey, TValue> {
field(key: TKey): Field<TValue | undefined>;
}
Note: behavior is not specified if the key is removed from the map. Do not use this method if the key may not be present in your map.
Collection & View Methods
Collections and views behave just like plain old (read-only, for views) JavaScript Arrays.
For convenience, there are a few additional methods added to these types, which help for common operations, to create derived views, and to observe changes to these objects over time.
Standard array methods
Under the hood, both Collections and Views use a Proxy to wrap around an underlying Array. This means they behave exactly like an Array, and all of the built-in methods are supported (and will be supported as the language evolves).
The following read-only methods are supported both on Collections and Views:
Array.prototype[@@iterator]()
Array.prototype.at()
Array.prototype.concat()
Array.prototype.entries()
Array.prototype.every()
Array.prototype.filter()
Array.prototype.find()
Array.prototype.findIndex()
Array.prototype.findLast()
Array.prototype.findLastIndex()
Array.prototype.flat()
Array.prototype.flatMap()
Array.prototype.forEach()
Array.prototype.includes()
Array.prototype.indexOf()
Array.prototype.join()
Array.prototype.keys()
Array.prototype.lastIndexOf()
Array.prototype.map()
Array.prototype.reduce()
Array.prototype.reduceRight()
Array.prototype.slice()
Array.prototype.some()
Array.prototype.toLocaleString()
Array.prototype.toString()
Array.prototype.values()
And the following methods that mutate the underlying array are only supported on Collections:
Array.prototype.copyWithin()
Array.prototype.fill()
Array.prototype.pop()
Array.prototype.push()
Array.prototype.reverse()
Array.prototype.shift()
Array.prototype.sort()
Array.prototype.splice()
Array.prototype.unshift()
.mapView()
Create a derived collection view holding transformed items.
interface Collection<T> {
mapView: <V>(
mapFn: (val: T) => V,
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
}
When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View
with mapped versions of those items by calling the provided mapFn
. This task is performed asynchronously.
The optional debugName
is only used for diagnostic purposes.
Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush()
to ensure the global dependency graph is processed and all updates have been performed.
Note: Unlike .map()
, .mapView()
’s callback function does not take an index or a reference to the original array as parameters.
Note: The order in which mapFn
is called is not defined and cannot be relied upon.
.filterView()
Create a derived collection view holding filtered items.
interface Collection<T> {
filterView: (
filterFn: (val: T) => boolean,
debugName?: string | undefined
) => View<T, ArrayEvent<T>>;
}
When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View
depending on if they pass the provided filter filterFn
. This task is performed asynchronously.
The optional debugName
is only used for diagnostic purposes.
Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush()
to ensure the global dependency graph is processed and all updates have been performed.
Note: Unlike .filter()
, .filterView()
’s callback function does not take an index or a reference to the original array as parameters.
Note: The order in which filterFn
is called is not defined and cannot be relied upon.
.flatMapView()
Create a derived collection view holding transformed, concatenated items.
interface Collection<T> {
flatMapView: <V>(
flatMapFn: (val: T) => V[],
debugName?: string | undefined
) => View<V, ArrayEvent<T>>;
}
When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View
with a concatenated set of items produced by applying the mapping flatMapFn
. This task is performed asynchronously.
The optional debugName
is only used for diagnostic purposes.
Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush()
to ensure the global dependency graph is processed and all updates have been performed.
Note:** Unlike .flatMap()
, .flatMapView()
’s callback function **does not take an index or a reference to the original array as parameters.
Note: The order in which flatMapFn
is called is not defined and cannot be relied upon.
Fun fact: .filterView()
and .mapView()
both use .flatMapView()
, which is a generalized transform + filter method.
.reject()
Mutate the collection to remove items from a collection that fail a callbackFn
function check. Like an in-place .filter()
for removing items.
interface Collection<T> {
reject: (callbackFn: (val: T) => boolean) => T[];
}
Note: the order in which callbackFn
is applied is not defined.
.moveSlice()
Move a slice of a collection to another location within the collection.
interface Collection<T> {
moveSlice: (fromIndex: number, count: number, toIndex: number) => void;
}
This function relocates a sequences of count
item from a starting fromIndex
to a destination toIndex
. toIndex
is the destination offset after removing count
items from fromIndex
.
Consider this function to be equivalent to: collection.splice(toIndex, 0, ...collection.splice(fromIndex, count))
Note: the difference between two splice operations is that moveSlice
does not cause mapped/filtered views to be re-mapped/re-filtered. This means that if a collection’s mapView()
is a JSX node, moveSlice
will cause the underlying DOM nodes to be relocated to the target location in the destination array, as opposed to being removed and rendered anew.
.subscribe()
Subscribe to changes to a target collection.
enum ArrayEventType {
SPLICE = 'splice',
MOVE = 'move',
SORT = 'sort',
}
type ArrayEvent<T> =
| {
type: ArrayEventType.SPLICE;
index: number;
count: number;
items?: T[] | undefined;
}
| {
type: ArrayEventType.MOVE;
from: number;
count: number;
to: number;
}
| {
type: ArrayEventType.SORT;
from: number;
indexes: number[];
};
interface Collection<T> {
subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}
When modifications are made to the target collection a task is scheduled to notify subscribers. The task to call handler
is performed asynchronously. The returned function can be called to unsubscribe.
The kinds of events are:
ArrayEventType.SPLICE
- a splice operation has been performed:count
values are removed and all theitems
are added at a targetindex
ArrayEventType.MOVE
- a run ofcount
items is moved from an startfrom
index to a destinationto
index.to
is the destination index after removingcount
items from thefrom
index.ArrayEventType.SORT
- a subset of the collection was reordered with a new set of indexes. Theindexes
array is always is zero-indexed, even iffrom
is greater than zero.
Note: Notifications for individual item assignments .push()
, etc… are all encoded as ArrayEventType.SPLICE
operations.
For convenience, a function applyArrayEvent
is exported, which allows you to apply an array event to a target array:
function applyArrayEvent<T>(target: T[], event: ArrayEvent<T>): void;
Field Methods
Fields are single values that Gooey tracks reads from & writes to. It’s often more convenient to lump multiple field-like values into an object with model()
, but sometimes just a single piece of state will do.
.get()
Retrieve the value from a field.
interface Field<T> {
get: () => T;
}
.set()
Set the value in a field.
interface Field<T> {
set: (val: T) => void;
}
.subscribe()
Subscribe to changes to the value associated with the field.
type FieldSubscriber<T> = (error: undefined, val: T) => void;
interface Field<T> {
subscribe: (observer: FieldSubscriber<T>) => () => void;
}
The returned function unsubscribes from the field.
When the field is modified, a task is scheduled to notify subscribers. This task to call observer
is called asynchronously. If a field is modified multiple times before notification, the observer
will only be called once with the last value at the next notification. The first error
parameter is always undefined
.
Note: The callback will not be called with any field values written before subscription starts, even if the writes and subscriptions are called within the same batch for a flush()
.
.retain() and .release()
Retain and release a field
interface Field<T> {
retain: () => void;
release: () => void;
}
There is no reason to call these functions directly.
Fields have an internal reference count. When this reference count is greater than 0, the field is “active”, present in the global dependency graph, and subscriptions are processed. When subscribing to a field, or when a field is bound to the DOM, it is automatically retained.
Calculation Methods
Calculations are functions which are automatically memoized/cached and get recalculated when their dependencies change. There are a few methods on calculation objects to configure the equality check, subscribe to changes, and handle errors.
.onErr()
Set the error handler for a calculation.
class CycleError extends Error {
...
}
interface Calculation<T> {
onError: (handler: (error: Error) => T) => this;
}
When an active calculation encounters an error, the error handler is invoked and its return value is used is used as the result of the calculation. Calculations that depend on this calculation and subscriptions to this calculation will be completely unaware that the error has occurred.
There are two kinds of errors that can occur: * A cycle error, when the calculation is identified as being part of a cycle (it has a self-dependency, or depends on a value that depends on itself. In this case the error
is an instance of the CycleError
class. * An uncaught exception thrown when calling the calculation’s function.
A calculation with an error will be recalculated (and its error handler called if present and an error occurs in recalculation) if any of its dependencies have changed.
.setCmp()
Set the comparison function for a calculation.
interface Calculation<T> {
setCmp: (eq: (a: T, b: T) => boolean) => this;
}
If a calculation is recalculated and its comparison function returns true, things that depend on the calculation will not be notified that a dependency has changed.
By default, this comparison function is strict equality: (a, b) => a === b
.
.subscribe()
Subscribe to calculation recalculation events.
type CalcUnsubscribe = () => void;
interface Calculation<T> {
subscribe: (handler: (value: T) => void) => CalcUnsubscribe;
}
The handler
will be called with the new result each time the calculation is recalculated. The returned value is a function which can be called to unsubscribe from the subscription.
If the recalculation is an error, the handler
will not be called.
.subscribeWithError()
Subscribe to calculation recalculation events and errors.
type CalcUnsubscribe = () => void;
class CycleError extends Error {
...
}
interface Calculation<T> {
subscribeWithError: (handler: (...args: [error: undefined, value: T] | [error: Error, value: undefined]) => void) => CalcUnsubscribe;
}
The handler
will be called with the new result each time the calculation is recalculated or has an error. The returned value is a function which can be called to unsubscribe from the subscription.
If the result of the recalculation is not an error, the error
parameter will be undefined
and value
will be the result of the calculation.
If the result of the recalculation is an uncaught error, the error
will be the Error
instance, and val
will be undefined
. If the calculation became part of a cycle, the error
will be an instance of CycleError
.
.retain() and .release()
Retain and release a calculation
interface Calculation<T> {
retain: () => void;
release: () => void;
}
In most cases, you should not need to call this function.
Calculations have an internal reference count. When this reference count is greater than 0, the calculation is “active”: it will be automatically memoized & automatically recalculated when its dependencies change. When the reference count is equal to 0, the calculation is “inert”: it will behave just like a normal function.
Gooey internally retains calculations when they are a dependency / bound to JSX, so this function is only needed if you want to keep memoization and recalculation when the calculation is not actively used by the system.
Do not rely on the recalculation behavior of retained calculations to trigger “side effects”, instead use .subscribe()
.
JSX
JSX is used by Gooey to create “render nodes” which it uses when rendering to the DOM.
In order for things to work right, your JS compiler toolchain should be configured to
- Compile JSX expressions into
Gooey.createElement
- Compile JSX Fragment expressions into
Gooey.Fragment
This can be accomplished with a .tsconfig
file containing:
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "Gooey",
"jsxFragmentFactory": "Gooey.Fragment"
}
}
There are two types of tags in JSX:
- Component JSX tags start with capital letters, like
<MyComponent />
, which render function / class components - Intrinsic Element JSX tags start with a lowercase letter, like
<div />
, which represent built-in or custom DOM elements
mount()
Render and attach JSX to the DOM.
function mount(target: Element, jsx: RenderNode): () => void
Until mounted or retained, a JSX expression evaluates to an inert data structure called RenderNode
. Mounting a JSX expression causes the expression to be rendered to HTML and attached to a target DOM Element
. The returned function unmounts the JSX from the DOM.
Note: It is recommended, but not necessary for the target
DOM element to be empty. If the target element contains children, Gooey will append the rendered JSX to the end of the existing child elements. Behavior is undefined if the set of target
‘s child nodes are changed outside of Gooey’s knowledge while JSX is mounted.
Note: Mounting is a very cheap operation. Do not hesitate to mount to multiple areas of the DOM (i.e. it’s perfectly safe to mount to document.head
to add additional nodes in the document head, or mount to the title element to provide a dynamic title.). Internally, every intrinsic element rendered performs a mount to render its contained JSX.
Element Props
Intrinsic Element JSX props correspond to the HTML name of the attribute. JSX props should look a whole lot like HTML properties.
React-like camelCase / attribute names are not supported. Event handlers are a bit different, which we’ll see later:
Will Not Work | Please Use Instead |
---|---|
<div className="my-block" /> | <div class="my-block" /> |
<label htmlFor="my-input" /> | <label for="my-input" /> |
<input type="text" enterKeyHint="search" /> | <input type="text" enterkeyhint="search" /> |
<button onClick={handleClick} /> | <button on:click={handleClick} /> |
There are a few special Intrinsic Element JSX props:
The ref
prop:
The ref
Intrinsic Element JSX prop can either be a function or an object created with the ref()
function.
If provided a function, it is passed a reference to the element when it is mounted or null when it is unmounted.
If provided a ref object, its .current
property is set to a reference to the element when it is mounted or null when unmounted.
For example, to obtain a reference to a div
element, you can use a callback function as the ref prop:
<div ref={(divEl: HTMLDivElement | undefined) => {
if (divEl) {
console.log('div mounted');
} else {
console.log('div unmounted');
}
} />
Event Handlers
Props prefixed with on:
add standard DOM event handlers. oncapture:
and onpassive:
can be used to add useCapture
and passive
DOM event handlers, respectively.
For example:
JSX | Equivalent JavaScript |
---|---|
<input on:input={onChange} /> | inputEl.addEventListener('input', onChange) |
<input oncapture:keydown={onKeyDown} /> | inputEl.addEventListener('keydown', onKeyDown, { capture: true }) |
<div onpassive:scroll={onScroll} /> | divEl.addEventListener('scroll', onScroll, { passive: true }) |
This syntax allows you to listen for custom events as naturally as you would for native events.
Component JSX Props
Component JSX props are passed as an object to component render functions / class constructors.
If a component is passed children via JSX, they are accessible via the children
prop. If a single child is passed, children
is the value of the child. If multiple children are passed, children
is an array of values.
Aside from children
, there are no special Component JSX props, all other props are passed as-is.
Calculation / Field props
If a calculation or field is passed as the value of a prop, the calculation’s result/field’s value is bound to the prop and will be automatically updated as the calculation is recalculated/field is updated.
Style & CSS props
Props prefixed with style:
bind to values on the style object. Props prefixed with cssprop:
bind to custom properties with an added --
prefix.
JSX | Equivalent JavaScript |
---|---|
<div style:color="red">Hello</div> | divEl.style.setProperty('color', 'red') |
<div cssprop:my-prop="3px">Hello</div> | divEl.style.setProperty('\-\-my-prop', '3px') |
This syntax allows you to conveniently bind calculations and fields to custom or built-in CSS properties.
The attr:
and prop:
props:
When interacting with 3rd party web components, it’s sometimes necessary to be very specific about whether or not you are setting an element’s attributes to a value, or setting a property.
Props prefixed with attr:
are set as element attributes, and those prefixed with prop:
are set as element member properties.
JSX | Equivalent JavaScript |
---|---|
<input attr:value="3" /> | inputEl.setAttribute('value', '3') |
<input prop:value="3" /> | inputEl.value = '3'; |
Compiled JSX: createElement()
When JSX is compiled to JavaScript, it becomes calls to Gooey.createElement
.
function createElement<TProps>(
type: string | Component<TProps>,
props: TProps,
...children: JSX.Node[]
): IntrinsicRenderNode | ComponentRenderNode<TProps>
You shouldn’t ever need to call createElement
manually, it should be taken care of as part of writing JSX.
<Fragment>
<Fragment>...</Fragment>
or <>...</>
is used to render any number of JSX children side-by-side.
const Fragment: Component<{ children?: JSX.Node | JSX.Node[] }>
A Fragment is equivalent to rendering an array of children in place, it’s just a bit more convenient.
JSX Child Types
There are a few types exported to the global JSX
namespace both for tooling and for convenience:
JSX.Element
is the type produced by evaluating a JSX expression.JSX.Node
is the type that is renderable as a JSX child. This is a non-standard type, but often helpful to reference.
type JSXNode =
| string
| number
| boolean
| null
| undefined
| bigint
| symbol
| Function
| Element
| RenderNode
| Calculation<JSXNode>
| Collection<JSXNode>
| View<JSXNode>
| Field<JSXNode>
| Array<JSXNode>;
namespace JSX {
/**
* The core type produced by a JSX expression
*/
type Element = RenderNode;
/**
* The core type allowable as a child node in a JSX expression
*/
type Node = JSXNode;
}
The effect of rendering each JSX.Node
type is as follows:
Value Type | Display |
---|---|
string / number / bigint | Rendered as a Text node with the value's string representation |
boolean / null / undefined | Not rendered at all |
function / symbol | Not rendered at all, and a warning is logged. |
Element | Element objects are rendered as-is, so <div>{document.createElement('div')}</div> works as you would expect. |
Calculation<JSXNode> | Calculations that produce JSX.Node types are bound in location to the DOM. When the calculation is recalculated, the prior result is removed and the next result is inserted in place. |
Collection<JSXNode> / View<JSXNode> | Collections and views that contain JSX.Node types are bound in location to the DOM, with their items placed sequentially. As the collection/view changes has items added/removed/relocated, the corresponding DOM nodes are added/removed/relocated. |
Array<JSXNode> | Arrays of JSX.Node types are rendered in location to the DOM, with their items placed sequentially. |
All other values will not be rendered at all, but a warning will be logged.
ref()
Ref objects hold mutable references to values, which may be changed over time. Ref callbacks receive references to values over time.
interface Ref<T> {
current: T | undefined;
}
function ref<T>(val?: T): Ref<T> {
return new Ref(val);
}
type RefCallback<T> = (val: T | undefined) => void;
type RefObjectOrCallback<T> = Ref<T> | RefCallback<T>;
An intrinsic element’s ref
prop can be set to either a Ref
object or a RefCallback
function.
When the element is mounted, the ref is assigned to/called with the underlying DOM Element
. When the element is unmounted, the ref is assigned to/called with undefined
.
Note: Because JSX may be detached and reattached, it is possible for the same DOM Element to be mounted and unmounted multiple times.
.retain() & .release()
Increment/decrement the refcount for JSX.
When it is greater than zero, the JSX is rendered to DOM nodes and dynamic behavior is “active”.
When it reaches zero, the JSX and its underlying resources will be freed.
interface RenderNode {
retain: () => void;
release: () => void;
}
Under the hood, Gooey uses refcounting to determine if a data types and JSX nodes is actively used. A piece of JSX will retain its children while alive, and release its children when destroyed.
When a piece of JSX is retained, it is considered to be “active”. This means that the underlying DOM nodes will be created and any calculations/collections/views that contribute to that DOM tree will be processed. The JSX does not need to be attached to the DOM for it to be retained.
This allows for a few different capabilities:
- Large portions of JSX anticipated to be rendered can be rendered “detached” so that it can be quickly attached to the DOM without needing to build the entire tree.
- Elements like
<canvas>
that hold rendering contexts may be temporarily removed and reattached without destroying the underlying rendering context and drawn content. - Components that create and hold state may be temporarily detached and reattached without losing that state.
IntrinsicObserver()
A built-in component to observe child DOM elements as they are added and removed from the DOM over time.
export enum IntrinsicObserverEventType {
MOUNT = 'mount',
UNMOUNT = 'unmount',
}
export type IntrinsicObserverNodeCallback = (
node: Node,
event: IntrinsicObserverEventType
) => void;
export type IntrinsicObserverElementCallback = (
element: Element,
event: IntrinsicObserverEventType
) => void;
const IntrinsicObserver: Component<{
nodeCallback?: IntrinsicObserverNodeCallback | undefined;
elementCallback?: IntrinsicObserverElementCallback | undefined;
children?: JSX.Node | JSX.Node[];
}>
This component allows for observing child DOM elements without knowing any information about the structure or behavior of its children passed.
There are two event
values passed to each callback:
IntrinsicObserverEventType.MOUNT
- called immediately after a child Element/Node has been mounted to the DOMIntrinsicObserverEventType.UNMOUNT
- called immediately before a child Element/Node has been unmounted from the DOM
The two different callbacks allow for different levels of specificity:
nodeCallback
is called with allNode
subtypes (Text
,Element
,CData
, etc…)elementCallback
is called with onlyElement
subtypes
Note: If IntrinsicObserver
is used on a detached/attached RenderNode, it may be mounted / unmounted with children. In this case, the nodeCallback
/ elementCallback
callbacks will be called at the corresponding mount / unmount lifecycle.
This can be used to accomplish a number of common tasks where a component needs to add event listeners to its children, but doesn’t care about how those children are constructed.
For example, here’s a component that adds a click
event handler to all of its children, regardless of how and when those children are rendered (even if they’re dynamically added & removed):
import Gooey, { Component, IntrinsicObserver } from '@srhazi/gooey';
interface Props {
onClick: (event: MouseEvent, el: Element) => void;
children?: JSX.Node[] | JSX.Node;
}
const ClickHandler: Component<Props> = ({ onClick, children }) => (
<IntrinsicObserver
elementCallback={(element, event) => {
if (event === 'mount' && element instanceof HTMLElement) {
element.addEventListener('click', (event) =>
onClick(event, element)
);
}
if (event === 'unmount' && element instanceof HTMLElement) {
element.removeEventListener('click', (event) =>
onClick(event, element)
);
}
}}
>
{children}
</IntrinsicObserver>
);
Components & DOM
Components allow for abstracting JSX into composable pieces, which each can hold their own state / manage their interface.
interface ClassComponentConstructor<TProps> {
new (props: TProps): ClassComponent<TProps>;
}
type Component<TProps = {}> =
| FunctionComponent<TProps>
| ClassComponentConstructor<TProps>;
There are two types of components, which both have equivalent capabilities:
- Function components are functions which take props & lifecycle handlers and return JSX
- Class components are classes that take props in their constructor, optionally have lifecycle handler methods, and have a
.render()
method which returns JSX
Each have the exact same capabilities and are provided separately only for stylistic reasons:
- Sometimes a component is simple enough that everything can be done in one go; a function component is best
- Other times a component grows enough complexity so that it’s more clear to have separate initialization, rendering, and internal helper methods; a class component is best.
Function components
Function components are functions that start with a capital letter, take props & lifecycle handlers as parameters, and return JSX.
const UnusedSymbolForChildrenOmission: unique symbol;
type EmptyProps = { [UnusedSymbolForChildrenOmission]?: boolean };
interface ComponentLifecycle {
onMount: (callback: () => void) => (() => void) | void;
onUnmount: (callback: () => void) => void;
onDestroy: (callback: () => void) => void;
onError: (handler: (e: Error) => JSX.Element | null) => void;
}
type FunctionComponent<TProps = {}> = (
props: TProps & EmptyProps,
lifecycle: ComponentLifecycle
) => JSX.Element | null;
A function component is a function that takes props & lifecycle handlers and returns JSX (or null
).
The lifecycle handlers passed as a second parameter to the component allow the component to hook into various events that occur during the lifecycle of the component:
onMount
is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOMonUnmount
is called immediately before the component is detached from the DOMonDestroy
is called when the component is no longer attached to the DOM and nothing retains the componentonError
is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component
While the typical lifecycle of a component is for it to be rendered, mounted, and then later on mounted and destroyed; it’s possible for a component to be mounted and unmounted multiple times and even detached and moved to another location in the DOM. The overall lifecycle of a component is:
Note: The EmptyProps
type is to disallow children from being passed to a component that takes no props. See component children for more information.
Note: The render function is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc()
functions that return JSX.
Class components
Class components extend ClassComponent
and have a constructor that takes props as the only parameter:
const UnusedSymbolForChildrenOmission: unique symbol;
type EmptyProps = { [UnusedSymbolForChildrenOmission]?: boolean };
class ClassComponent<TProps = EmptyProps> {
props: TProps;
constructor(props: TProps) {
this.props = props;
}
render?(): JSX.Element | null;
onMount?(): (() => void) | void;
onUnmount?(): void;
onDestroy?(): void;
onError?(e: Error): JSX.Element | null;
}
The optional lifecycle methods allow the component to hook into various events that occur during the lifecycle of the component:
onMount
is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOMonUnmount
is called immediately before the component is detached from the DOMonDestroy
is called when the component is no longer attached to the DOM and nothing retains the componentonError
is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component
While the typical lifecycle of a component is for it to be constructed & rendered, mounted, and then later on mounted and destroyed; it’s possible for a component to be mounted and unmounted multiple times and even detached and moved to another location in the DOM. The overall lifecycle of a component is:
Note: The EmptyProps
type is to disallow children from being passed to a component that takes no props. See component children for more information.
Note: The .render()
method is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc()
functions that return JSX.
Component children
Sometimes it’s desirable for a component to accept children, so it can place that children somewhere in its area of concern.
Children aren’t necessarily limited to JSX nodes. It’s possible for a child to be an object
, a function
, or any value at all.
Components may specify in their type signature the number and kind of children that they accept.
Due to historical reasons, the types are a bit awkward, so for reference here are the recommended variations:
import Gooey, { Component } from '@srhazi/gooey';
// If the `children` prop is not specified, it is a type error to pass children to this component
const AcceptsNoChildren: Component<{ name: string }> = ({ name }) => (/* ... */);
// It will be a type error to pass more than one child to this component
const AcceptsOptionallyOneChild: Component<{ children?: JSX.Node }> = ({ children }) => (/* ... */);
// It will be a type error to pass zero or 2+ children to this component
const AcceptsExactlyOneChild: Component<{ children: JSX.Node }> = ({ children }) => (/* ... */);
// It will be a type error to pass zero or 2+ children to this component
const AcceptsMoreThanOneChild: Component<{ children: JSX.Node | JSX.Node[] }> = ({ children }) => (/* ... */);
Note: the quantity of children change the type of the children
prop:
- If a single child value is passed to a component, children will be its
children
prop. - If multiple children are passed to a component, an array of those children will be its
children
prop.
Note: It is not valid for an intrinsic element to be attached to two different places at the same time. Attempts to place children
in multiple locations in the JSX tree will cause an exception to be thrown.
Web Components
In addition to JSX expressions that can be mounted to elements, Gooey allows you to define custom DOM elements using the Web Components APIs.
defineCustomElement()
Custom elements are user-defined custom HTML tags which contain a hyphen, like <my-label>...</my-label>
or <cool-button>...</cool-button>
.
When defining a custom element, users may specify the list of attributes to be observed for changes, and provide a Component
function which allows for customization of contents and behavior.
Custom elements are initialized when they are mounted to the document, and uninitialized when they are unmounted. Element instances have a .retain()
and .release()
pair of methods, which allow for preventing the uninitialization while unmounted.
Custom elements can be used to add dynamic functionality to plain HTML documents without needing to mount()
components to the page.
interface WebComponentOptions<
TKeys extends string,
TShadowMode extends 'open' | 'closed' | undefined,
TExtends extends string | undefined
> {
tagName: `${string}-${string}`;
Component: WebComponent<TKeys, TShadowMode>;
observedAttributes?: TKeys[] | undefined;
formAssociated?: boolean | undefined;
shadowMode?: TExtends extends ShadowSupportedTags ? TShadowMode : undefined;
delegatesFocus?: boolean | undefined;
extends?: TExtends;
hydrateTemplateChild?: boolean | undefined;
}
function defineCustomElement<
TKeys extends string,
TShadowMode extends 'open' | 'closed' | undefined = undefined,
TExtends extends string | undefined = undefined
> (options: WebComponentOptions<TKeys, TShadowMode, TExtends>): void;
The options
object provided to defineCustomElement
has fields:
tagName
(required): the name of the tag, which must be avalid custom element nametl;dr: it must contain a hyphen ( `
)Component
(required): the component function; See Custom Component Functions belowobservedAttributes
: an array of string attributes that your component function is interested inshadowMode
: pass'open'
or'closed'
to have your component render to be a “Shadow Component” and render to the Shadow DOM; otherwise it is a “traditional component”; more on this belowformAssociated
: passtrue
here if your component should participate in Form SubmissiondelegatesFocus
: passtrue
here component Focus Delegationextends
: pass the tag name this component is extending; Note: this DOES NOT work in SafarihydrateTemplateChild
: passfalse
here if your component is expected to be present in the DOM at the time of its registration where it has a single<template>
element child; Note: this almost certainly will never be the case.
Web Component Functions
Custom Component Functions behave like standard Component functions (see the Function component documentation), but their lifecycle handlers have additional functionality.
export interface WebComponentLifecycle extends ComponentLifecycle {
host: HTMLElement;
shadowRoot: ShadowRoot | undefined;
elementInternals: ElementInternals | undefined;
addEventListener(
type: string,
listener: (this: HTMLElement, ev: Event, el: HTMLElement) => any,
options?: boolean | AddEventListenerOptions,
): void;
bindElementInternalsAttribute: (
param: WebComponentInternalsKey,
value: Dyn<string | null>,
) => () => void;
bindFormValue: (formValue: Dyn<FormValue>) => () => void;
bindValidity: (validity: Dyn<Validity>) => () => void;
checkValidity: () => void;
reportValidity: () => void;
}
type WebComponentProps<
TKeys extends string,
TShadowMode extends 'open' | 'closed' | undefined
> = TShadowMode extends undefined
? { [Key in TKeys]?: Dyn<string | undefined>; } & { children: JSXNode; }
: { [Key in TKeys]?: Dyn<string | undefined>; };
type WebComponent<
TKeys extends string,
TShadowMode extends 'open' | 'closed' | undefined
> = (
props: WebComponentProps<TKeys, TShadowMode>,
lifecycle: WebComponentLifecycle,
) => JSX.Element | null;
A web component function takes props & lifecycle handlers and returns JSX (or null
).
The props passed to a web component are dynamic string
or undefined
values. Use dynGet()
and dynSubscribe()
to access prop values.
The children
prop is only provided to web components that do not render to the Shadow DOM. If you are rendering to the Shadow DOM, you must use <slot>
elements and slot
attributes to place children.
The lifecycle handlers passed as a second parameter to the component allow the component to hook into various events that occur during the lifecycle of the component.
The standard component lifecycle handlers are present:
onMount
is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOMonUnmount
is called immediately before the component is detached from the DOMonDestroy
is called when the component is no longer attached to the DOM and nothing retains the componentonError
is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component
Additionally, web components have the following values in their lifecycle parameter:
host
: a reference to the host ElementshadowRoot
: a reference to the element’s shadow root (ifshadowMode
is'open'
or'closed'
)elementInternals
: a reference to the element’s ElementInternals instance (ifextends
is not provided)addEventListener
: a convenience method to add an event listener to the host element. The event listener is automatically removed when deinitialized.bindElementInternalsAttribute
: a convenience method to bind a dynamic value to anElementInternals
member.bindFormValue
: a convenience method to bind a dynamic value to the element’s underlying form value via setFormValuebindValidity
: a convenience method to bind a dynamic value to the element’s validity via setValiditycheckValidity
: a convenience method to check the validity of the element via checkValidityreportValidity
: a convenience method to report the validity of the element via reportValidity
Note: The extends
option is not supported (and unlikely to ever be supported) on Safari. See https://github.com/WebKit/standards-positions/issues/97 for more information.
Note: The render function is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc()
functions that return JSX.
Dynamic Helpers
Gooey comes with a few helper functions which aid in building components.
These are all helpers that handle accessing both static data (like a plain old JavaScript string) and data that is wrapped as a field
or calc
.
These helpers are all prefixed with dyn
:
dynGet()
dynSet()
dynSubscribe()
type Dyn<T>
When building components, it can be helpful to export a prop that can be passed either a calculation, field, or plain old value.
The Dyn<T>
type allows for annotating theses sorts of flexible items.
type Dyn<T> = T | Field<T> | Calculation<T>;
See dynGet(), dynSet(), and dynSubscribe() for more information.
As an example, the following code shows how a reactive checkbox component could be created. Its “checked” prop will be bound to the checked state.
import Gooey, {
Component,
mount,
field,
calc,
Dyn,
dynGet,
dynSet,
dynSubscribe,
ref,
} from '@srhazi/gooey';
// A checkbox component which takes a dynamic "checked" prop value and label
const Checkbox: Component<{
checked?: Dyn<boolean>;
label: JSX.Node;
}> = ({ checked = field(false), label }, { onMount }) => {
const inputRef = ref<HTMLInputElement>();
return (
<label>
<input
ref={inputRef}
type="checkbox"
checked={checked}
on:input={(e, el) => {
if (!dynSet(checked, el.checked)) {
// Failed to set for some reason, revert to the checkd value
el.checked = dynGet(checked);
}
}}
/>{' '}
{label}
</label>
);
};
const checkedField = field(false);
const checkedCalc = calc(() => !checkedField.get());
mount(
document.body,
<>
<p>
<Checkbox label="No checked prop specified, brings its own state" />
</p>
<p>
<Checkbox checked label="Always true" />
</p>
<p>
<Checkbox checked={checkedField} label="A field which can be toggled" />
</p>
<p>
<Checkbox
checked={checkedCalc}
label="A calc which is the opposite of the field"
/>
</p>
<button on:click={() => checkedField.set(!checkedField.get())}>
Toggle field
</button>
</>
);
dynGet()
Read the value from a Dyn If the provided Write a value to a Dyn If the provided Otherwise, Subscribe to a stream of values for a Dyn If the provided Otherwise, Gooey keeps track of reads and writes to all of the authoritative and derived data types it supports. By also tracking reads within When a piece of data has changed, the global dependency graph needs to be processed in order for derived data to be updated and subscriptions to be notified. By default, Gooey updates derived data types (collection views and calculations) and calls subscriptions to data in batches, asynchronously. This is performed as a microtask that gets scheduled when any piece of authoritative data is modified. To trigger a manual, synchronous processing of the global dependency graph, call To configure how processing is scheduled, use Note: Stale reads of derived data are possible if they are read synchronously after the data they depends on has changed. Reset all internal state This function should only be called in the context of a test. It resets all of the internal state of Gooey to an initial state. Note: The behavior for accessing data types that have been created prior to calling Provide a custom scheduler to perform a flush of the system. The By passing The default scheduler schedules processing at the end of the event loop via queueMicrotask (if possible, if not via Manually trigger processing of the global dependency graph. An example of how this is useful is in an event handler to synchronously update UI prior to leaving the event handler (so that you may transition focus to an element that is revealed via state change). Note: if called while processing the global dependency graph (i.e. within a calculation body while recalculating), the function call does nothing and is a no-op. Set & retrieve log level By default the log level is Get diagnostic information about the global dependency graph The The function dynGet<T>(wrapper: Dyn<T>): T
dyn
is a calculation, it is equivalent to dyn.get()
. If dyn
is a field, it is equivalent to dyn.get()
. If dyn
is any other value, it is equivalent to dyn
.
dynSet()
function dynSet<T>(wrapper: Dyn<T>, value: T): boolean
dyn
is a field, it is equivalent to dyn.set(value)
and true
is returned, indicating a successful write.true
is returned, indicating an unsuccessful write.
dynSubscribe()
function dynSubscribe<T>(wrapper: Dyn<T>, callback: (val: T) => void): () => void
dyn
is a field or calculation, it is equivalent to dyn.subscribe(handler)
. The returned function unsubscribes from the subscription.dyn
is a plain old value. In this case, handler
is called with the plain old value, and a noop function is returned. No further calls to handler
will be performed.
Processing Engine
calc()
functions, it can build a global dependency graph, which is used to ensure derived data is recalculated in the correct order.flush()
.subscribe()
.
reset()
function reset(): void
reset()
is not defined and should not be relied upon
subscribe()
function subscribe(scheduler?: ((performFlush: () => void) => () => void) | undefined): void
scheduler
function is called when Gooey determines that the global dependency graph needs to be processed. It is provided a performFlush
callback that processes the global dependency graph when called. The scheduler
function should return a function that prevents it from calling performFlush
in the future.undefined
as the scheduler, automatic processing is disabled and the global dependency graph is not automatically processed.setTimeout(performFlush, 0)
.
flush()
function flush(): void
setLogLevel() & getLogLevel()
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
function getLogLevel(): LogLevel;
function setLogLevel(logLevel: LogLevel);
'warn'
.
debug() & debugSubscribe()
function debug(activeVertex?: Processable | undefined, label?: string | undefined): string;
function debugSubscribe(fn: (label: string, graphviz: string) => void): () => void
debug()
function will return a graphviz dot formatted representation of the global dependency graph.debugSubscribe()
function will call the callback fn
with a stream of graphviz dot formatted representations of the global dependency graph as it changes and is processed. This function generates tremendous amounts of data, do not use it.