@praxisjs/decorators
Changelog for @praxisjs/decorators — @State, @Prop, @Computed, @Watch, @Emit, @Compose, and all built-in decorators.
@praxisjs/decorators
2.0.1
Depends on the other @praxisjs/* packages through caret ranges (^2.3.0) instead of exact versions, so npm no longer installs a second copy of a package when two releases disagree, and upgrades no longer end in peer conflicts.
2.0.0
Breaking: the accessor APIs are removed. PraxisJS no longer needs the accessor keyword anywhere.
createAccessorDecorator(and itsAccessorBehavior/AccessorBindingtypes) is removed. UsecreateFieldDecoratorwith the newsetupform on a plain field:bind(instance, name, initialValue)becomesinit(instance, initialValue)storing state in a symbol slot, and the returnedget/setbecome the sharedget/set.@Computed({ get, set })declared on anaccessorfield is removed. Write a@Computed()getter with asetaccessor of the same name next to it.
See Migrating from createAccessorDecorator for before/after code.
Shared setup form for every builder. createFieldDecorator, createMethodDecorator, createGetterDecorator and createWritableGetterDecorator accept setup, which runs once per decorated member and returns functions shared by every instance (this is the instance, per-instance state lives in symbol slots). Building an instance allocates nothing for them and every instance keeps one hidden class. Measured per instance with three decorated members: field decorator 870 → 320 ns (bind → setup), method decorator 31 ns, writable getter 57 ns, and a decorated getter read 10 → 0.9 ns.
createFieldDecorator<object, number>({
setup(name) {
const slot = Symbol(name)
return {
get(this: Slots) { return (this[slot] as Signal<number>)() },
set(this: Slots, v) { (this[slot] as Signal<number>).set(v) },
init(instance, initial) { (instance as Slots)[slot] = signal(initial) },
}
},
})setupresults can carryonMount(instance)/onUnmount(instance)hooks (fields, methods through{ method, onMount, onUnmount }, getters), andinit(instance, …)for per-instance slots.ClassBehaviorhooksonMount(instance),onUnmount(instance)andrender(instance, originalRender)can be defined on the behavior itself, with no per-instance object.createis optional, and a behavior with onlyinitializeleaves the class unwrapped.createGetterObserverDecoratortakesregister(getter, instance, name), run on every mount once the fields are set and undone on unmount. A function returned byobservenow runs on unmount.- Every builder takes a
labelused in error messages, and checks the member kind when the class is defined:@Clamp() decorates getters only, not the method "reset".Wrongbind/setup/wrapresults throw aTypeErrornaming the decorator.
Typing. createFieldDecorator<Host, Value>, createGetterDecorator<Host, Value>, createMethodDecorator<Host, M> and createLifecycleMethodDecorator<Host, Args> restrict the members a decorator accepts, so TypeScript rejects a number decorator on a string field. Value is inferred from get/set. New exported types: FieldDecorator, FieldSetup, SharedFieldBehavior, MethodDecorator, MethodSetup, SharedMethodBehavior, GetterDecorator, GetterSetup, SharedGetterBehavior, SharedWritableGetterBehavior.
Faster per-instance forms.
createMethodDecorator({ wrap })keeps each instance's wrapper in a slot behind one replacement on the class instead of defining a property per instance: 288 → 71 ns per instance with three methods. Reading the method returns it bound, so detached calls (onClick={this.save}) work, and it now stacks under built-in method decorators such as@Once()instead of hiding them.createWritableGetterDecorator({ bind })reads each instance's pair through one accessor on the class: 1119 → 137 ns per instance with three getters, and instances share one hidden class. Assigning a property whose binding has nosetstill throws aTypeError.@Computed()installs its getter once on the class instead of a property on every instance: 346 → 133 ns per instance with three computeds.
Bug fixes.
@Computed()with asetaccessor declared next to the getter threwTypeError: Cannot set property … which has only a getteron assignment. The writable pair now works, and a subclass can override a computed getter and read it throughsuper.
The published package no longer contains the package's tests (compiled tests in dist/__tests__, test sources in src/__tests__).
The package is marked "sideEffects": false, so bundlers leave out the modules of it that an app does not use.
@Selector(field, options?) — keyed selection.
@Selector('selected') isSelected!: Selector<number> exposes this.isSelected(key). Bindings that read it subscribe to their own key only, so changing selected re-runs the previous and the next row instead of every row in the list. { equals } takes a custom comparison such as (id, ids) => ids.includes(id) for multi-selection. No lifecycle cleanup is needed: subscriptions are released as rows are disposed.
@State() selected = 0
@Selector('selected') isSelected!: Selector<number>
<tr class={() => (this.isSelected(row.id) ? 'danger' : '')}>Cheaper per-instance setup.
@State()and@Prop()install one accessor pair per decorated field, shared by every instance, instead of new closures per instance. Instances of a class now share one hidden class, so field reads stay monomorphic, and each field costs a single property definition.@Computed()creates its computed the first time the getter is read, so a getter a component never reads costs nothing beyond its slot.@Component()marks the class itself instead of wrapping it in a subclass, which removed a per-instance object and a per-render closure. The decorated class is returned unchanged.@Bind,@Once,@Throttle,@Debounce,@Retry,@Log,@Memo,@Emitand@Untilreplace the method once on the class instead of defining a wrapper on every instance, and keep their per-instance state (timer, cache, last call) in symbol slots. Reading the method from an instance still returns a function bound to it, the same one on every read, created the first time it is read.vi.spyOn(instance, 'method')works before the first read as well: the spy wraps the method, which takesthisfrom each call through the spy. Building a component with these decorators no longer allocates anything for them.@Ref,@Command,@Slot,@FunctionProp,@History,@DeepState,@Persisted,@Synced,@Resource,@Composeand@Selectorshare one accessor pair per decorated field, like@State.@Ref,@Commandand@Selectorcreate their object on first read.@DeepStateuses one proxy handler per field instead of three closures per proxied object, and slots, persisted and synced signals are kept on the instance instead of inWeakMaps.- Decorators that hook into
onMount/onUnmount(@Watch,@When,@OnCommand,@History,@Persisted,@Synced,@Resource,@Compose,@Debounce, andcreateFieldDecorator/createLifecycleMethodDecoratorbindings) chain onto the instance's hooks with two closures and no bound functions.@Debounceonly hooks intoonUnmountonce the method has been called.@Watchand@Whendispose their effect directly. @Memobuilds the cache key of a single-argument call without an intermediate array.@Lazycreates one object per instance instead of three closures and a signal.
Instances stay in fast properties. With @praxisjs/vite-plugin, a field whose innermost decorator is @State(), @Prop(), any other built-in field decorator or a createFieldDecorator binding is no longer defined as a plain value first and then replaced: the decorator installs its property directly with the field's initial value. Replacing a value with an accessor made every instance a slow dictionary-mode object in V8. Order, stacked decorators, Object.keys() and spread are unchanged, and without the plugin fields behave as before. addClaimableInitializer(context, init) is exported for field decorators written against the TC39 API, so they get the same path.
Field decorators reject accessor members.
@State(), @Prop(), createFieldDecorator bindings and every other built-in field decorator now throw a TypeError when the class is defined if they decorate an accessor (or a method, getter or setter), with the member to fix in the message: @State() decorates plain class fields only: remove `accessor` from "count". Before, the decorator defined its property over the accessor, and decorators stacked on the same member wrapped the wrong one. assertFieldContext(context, label?) is exported for field decorators written directly against the TC39 API.
Bug fixes.
@Until('field')on a@State(or@Prop) field threw, because it passed the field's plain value where a signal was expected. It now waits on the field itself, as the docs show.- Calling
onMount()twice withoutonUnmount()in between left the first@Watch,@When,@OnCommandorcreateLifecycleMethodDecoratorregistration running forever. The previous registration is now stopped before the new one starts. - Stacking method decorators kept only one of them: with
@Log() @Throttle(100)only@Logran, and acreateMethodDecorator-based decorator (such as@Debug) placed above@Emitdropped@Emit. Every decorator in the stack now runs. - Method decorators on private methods (
@Once() #load()) did nothing; they now apply.
1.7.0
Fixed a leak in @Persisted: each component instance registered a window storage listener (when syncTabs is on) that was never removed, so mounting and unmounting a component repeatedly piled up listeners — every cross-tab write then deserialized the value once per leftover instance. The listener is now closed on unmount. The field also reads through the signal captured at bind time instead of two map lookups per read.
@Memo now bounds its cache: it keeps the 100 most recently used argument combinations and drops the least recently used one beyond that, releasing its subscriptions unless a binding is still reading it. @Memo({ max }) changes the limit and max: Infinity restores the old unbounded cache. The only observable difference is that a very old combination is recomputed the next time it is called. @History now stops tracking when the component unmounts.
@DeepState now returns one proxy per nested object instead of a new Proxy on every property read, so this.config.theme === this.config.theme holds and reads no longer allocate. It also no longer notifies when a property is assigned the value it already has or when a missing key is deleted, and an array push notifies once instead of twice.
1.6.0
@Command<T>() — field decorator syntax for createCommand().
Wraps createCommand() as a field decorator, matching the @Ref<T>() pattern. Each decorated field gets its own Command instance, created once per component instance:
import { Command } from '@praxisjs/decorators'
@Component()
class Player extends StatefulComponent {
@Command() play!: Command
@Command() pause!: Command
@Command<number>() seek!: Command<number>
}Calling createCommand() directly as a field initializer still works and is unaffected.
1.5.1
@FunctionProp() now logs a console.warn in development when the received value isn't a function, instead of silently accepting it.
1.5.0
@FunctionProp() for function-valued props and private runtime helpers.
Use @FunctionProp() when a parent passes a callback, predicate, formatter, or filtering function that should be received as the function value itself. @Prop() keeps its existing reactive getter behavior.
@Prop, @State, @DeepState, @Emit, @OnCommand, and @Lazy now read or update framework state through @praxisjs/core/internal helpers instead of component underscore fields. No application-code behavior changes.
ReactiveHost now uses type-only markers for components and stores instead of requiring a runtime _stateDirty field on non-component hosts.
1.4.0
@Ref<T>() — DOM element ref decorator.
Replaces the manual { current: null as T | null } pattern with a typed decorator. The decorated field becomes a Ref<T> — callable as the JSX ref callback and holding the element in .current:
import { Ref } from '@praxisjs/decorators'
@Component()
class Card extends StatefulComponent {
@Ref<HTMLDivElement>()
cardRef!: Ref<HTMLDivElement>
render() {
return <div ref={this.cardRef}>...</div>
}
}Ref<T> is both the decorator function and the type annotation — one import, used in both positions.
Works with @Compose exactly as before — pass the field name as a string:
@Ref<HTMLDivElement>()
containerRef!: Ref<HTMLDivElement>
@Compose(ElementSize, 'containerRef')
size!: ElementSizeFor refs outside of a class, use createRef<T>():
import { createRef } from '@praxisjs/decorators'
const scrollRoot = createRef<HTMLDivElement>()
// pass to @Lazy, store in module scope, etc.1.3.0
@Computed — writable computed with getter + setter support.
@Computed now accepts get and set options for a TypeScript-compatible compact form using the accessor keyword — TypeScript sees the field as read-write, no cast required:
@Computed({
get(this: MyComponent): number {
return (this.celsius * 9) / 5 + 32
},
set(this: MyComponent, value: number) {
this.celsius = ((value - 32) * 5) / 9
},
})
accessor fahrenheit!: numberAlternatively, pass only set alongside a plain get accessor. This has identical runtime behavior but TypeScript cannot see the setter:
@Computed({
set(this: MyComponent, value: number) {
this.celsius = ((value - 32) * 5) / 9
},
})
get fahrenheit(): number {
return (this.celsius * 9) / 5 + 32
}The getter is always a cached reactive computed() that recomputes when its signal dependencies change.
1.2.0
@When — optional condition function.
@When(propName, condition?) now accepts an optional predicate. The method fires exactly once, on the first value for which condition(value) returns true. Without a condition, the existing behaviour is preserved — fires on the first truthy value.
@When('score', score => score >= 100)
onWin() { ... }Internal fix: @When now reads the property inside a reactive effect(), so it works correctly with @State-decorated fields in addition to raw Signal/Computed properties.
Internal test coverage raised to 100% across all decorators.
1.1.0
@Resource — cache, SWR, deduplication, invalidation, refetch on focus.
New options forwarded to the core resource() primitive:
key— shared cache key. Resources with the same key share a cache entry and a single in-flight request.staleTime— ms before cached data is stale. At0(default) the cached value is shown immediately while fresh data loads in the background (SWR). Set toInfinityfor a fully static cache.refetchOnFocus— automatically refetches when the tab regains visibility.
New lifecycle behaviour:
- The decorator now calls
r.destroy()inonUnmount, ensuring focus listeners and cache registrations are cleaned up when the component is unmounted.
New export:
invalidateResource(key)— re-exported from@praxisjs/decoratorsfor convenience. Clears the named cache entry and triggers all active resources under that key to refetch.
Internal: serializeArgs in @Memo — removed an unreachable null branch from the catch block (JSON.stringify(null) never throws).
import { invalidateResource } from '@praxisjs/decorators'
async function saveProfile(data: ProfileData) {
await api.save(data)
invalidateResource('current-user') // refetches every component subscribed to this key
}1.0.2
@Resource now accepts a self-receiving fetcher — pass the component instance as the first argument to access reactive fields safely:
@Resource((self: MyComponent) => api.getPage(self.page))
items!: ResourceInstance<Item[]>When the fetcher accepts one argument, the component instance is passed automatically at bind time. Any signals read on self become reactive dependencies. The zero-argument form (() => api.getAll()) continues to work unchanged.
1.0.1
Updated dependencies — @praxisjs/[email protected].
1.0.0
Breaking — @Virtual removed
@Virtual is removed. It had two fundamental flaws: items were a snapshot at render time and never updated reactively, and JSX in renderItem silently failed on scroll.
Migrate to the VirtualList composable — it exposes reactive signals (visibleItems, totalHeight, offsetTop, offsetBottom) and renders with normal JSX.
Other changes in this release:
@Lazyfixed — the component now renders after entering the viewport. The previous implementation returned a staticnullfrom the render enhancement so the IntersectionObserver callback had no effect@Lazyoptions object — now accepts{ placeholder, root, rootMargin }for scoping intersection to a specific scroll container@Composestring literals —@Compose(KeyCombo, 'ctrl+s')no longer requires an intermediate instance property. Newgetter(propName)helper exported for live reactive sources- Bug fix — stacked class decorators (e.g.
@Lazy @Component) no longer cause infinite recursion
0.8.1
@State and @DeepState now produce a TypeScript error when applied to plain classes. Store classes must extend ReactiveStore from @praxisjs/store.
0.8.0
- New:
@Synced(channelName?)— syncs a field across browser tabs viaBroadcastChannel - New:
@DeepState()— deepProxyso nested mutations are reactive without replacing the reference
0.7.0
Breaking — @History redesigned
@History now decorates a separate field (not the @State field itself):
// before
@History(100) @State() text = ''
// after
@State() text = ''
@History('text', 100) textHistory!: HistoryOf<MyClass, 'text'>0.6.0
- New:
@Until(propName)— replaces the method with one that returns aPromiseresolving to the first truthy value @Memofalls back to object identity for non-JSON-serializable arguments@Debouncecancels its timer on component unmount@Throttleclamps negativemsto0
0.5.0
- Decorator factory helpers —
createFieldDecorator,createClassDecorator,createMethodDecorator,createLifecycleMethodDecorator,createGetterDecorator,createGetterObserverDecorator. See Creating Decorators - New:
@Compose— mixes aComposableclass into a component - New:
@Resource— async data field on a component
0.4.3
Bug fixes in @Lazy, @Virtual, and @Watch: type constraint incompatibility, infinite recursion on render after becoming visible, reactive effect leak on unmount.
0.4.0
Added @Computed() for read-only cached reactive getters. @Debug() now supports @Computed getters.
0.3.0
Migrated to TC39 decorator context API. Introduced StatefulComponent / StatelessComponent.
0.1.0
Initial beta release.