Morphos & Kosmesis are here.Explore
PraxisJS

@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 its AccessorBehavior / AccessorBinding types) is removed. Use createFieldDecorator with the new setup form on a plain field: bind(instance, name, initialValue) becomes init(instance, initialValue) storing state in a symbol slot, and the returned get/set become the shared get/set.
  • @Computed({ get, set }) declared on an accessor field is removed. Write a @Computed() getter with a set accessor 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) },
    }
  },
})
  • setup results can carry onMount(instance) / onUnmount(instance) hooks (fields, methods through { method, onMount, onUnmount }, getters), and init(instance, …) for per-instance slots.
  • ClassBehavior hooks onMount(instance), onUnmount(instance) and render(instance, originalRender) can be defined on the behavior itself, with no per-instance object. create is optional, and a behavior with only initialize leaves the class unwrapped.
  • createGetterObserverDecorator takes register(getter, instance, name), run on every mount once the fields are set and undone on unmount. A function returned by observe now runs on unmount.
  • Every builder takes a label used in error messages, and checks the member kind when the class is defined: @Clamp() decorates getters only, not the method "reset". Wrong bind/setup/wrap results throw a TypeError naming 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 no set still throws a TypeError.
  • @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 a set accessor declared next to the getter threw TypeError: Cannot set property … which has only a getter on assignment. The writable pair now works, and a subclass can override a computed getter and read it through super.

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, @Emit and @Until replace 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 takes this from 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, @Compose and @Selector share one accessor pair per decorated field, like @State. @Ref, @Command and @Selector create their object on first read. @DeepState uses 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 in WeakMaps.
  • Decorators that hook into onMount/onUnmount (@Watch, @When, @OnCommand, @History, @Persisted, @Synced, @Resource, @Compose, @Debounce, and createFieldDecorator / createLifecycleMethodDecorator bindings) chain onto the instance's hooks with two closures and no bound functions. @Debounce only hooks into onUnmount once the method has been called. @Watch and @When dispose their effect directly.
  • @Memo builds the cache key of a single-argument call without an intermediate array.
  • @Lazy creates 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 without onUnmount() in between left the first @Watch, @When, @OnCommand or createLifecycleMethodDecorator registration 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 @Log ran, and a createMethodDecorator-based decorator (such as @Debug) placed above @Emit dropped @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!: ElementSize

For 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!: number

Alternatively, 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. At 0 (default) the cached value is shown immediately while fresh data loads in the background (SWR). Set to Infinity for a fully static cache.
  • refetchOnFocus — automatically refetches when the tab regains visibility.

New lifecycle behaviour:

  • The decorator now calls r.destroy() in onUnmount, ensuring focus listeners and cache registrations are cleaned up when the component is unmounted.

New export:

  • invalidateResource(key) — re-exported from @praxisjs/decorators for 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:

  • @Lazy fixed — the component now renders after entering the viewport. The previous implementation returned a static null from the render enhancement so the IntersectionObserver callback had no effect
  • @Lazy options object — now accepts { placeholder, root, rootMargin } for scoping intersection to a specific scroll container
  • @Compose string literals — @Compose(KeyCombo, 'ctrl+s') no longer requires an intermediate instance property. New getter(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 via BroadcastChannel
  • New: @DeepState() — deep Proxy so 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 a Promise resolving to the first truthy value
  • @Memo falls back to object identity for non-JSON-serializable arguments
  • @Debounce cancels its timer on component unmount
  • @Throttle clamps negative ms to 0

0.5.0

  • Decorator factory helpers — createFieldDecorator, createClassDecorator, createMethodDecorator, createLifecycleMethodDecorator, createGetterDecorator, createGetterObserverDecorator. See Creating Decorators
  • New: @Compose — mixes a Composable class 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.

On this page