Skip to content

Migrating from v3 to v4

v4 is a full breaking major. There is no bridge release and no compatibility layer: @studiometa/js-toolkit 4.0 and @studiometa/ui 2.0 ship together.

Migration help is tooling, not runtime shims. Lint rules flag $children, $parent, updated() and the old handler signatures, and codemods cover mechanical renames only.

The component tree

$parent, $children and $root are removed

Parent and child are DOM ancestry, never ownership, so nothing holds a reference in either direction.

js
this.$children.SliderItem; 
this.$query('SliderItem'); 

this.$parent.goTo(index); 
this.$closest('Slider')?.goTo(index); 

this.$root.doSomething(); 
this.$closest('App')?.doSomething(); 

$query(name) returns a flat Base[] of all matching descendants, at any depth — not the keyed object $children was. $closest(name) is resolved on every access and returns Base | null, so guard it and never dereference it unguarded in mounted().

Better than either: a parent watches its children, and a child emits.

js
items = this.$watchChildren('SliderItem'); 

createApp() is removed

There is no root component and no application object.

js
createApp(App); 
registerComponents(Accordion, Slider, TodoList); 

A component is registered, not mounted by a parent, so nothing has to own the page.

ChildrenManager is gone

A nested data-component is discovered by the registry on its own. config.components still exists, but it now declares a family — it registers those names and supplies the name set that on<Child><Event> resolution needs. It does not construct anything.

Unmounting a parent no longer unmounts its children.

Refs

$update() is removed

Refs are live: each $refs property reads the DOM on access.

js
this.$refs.list.append(li);
this.$update(); 
console.log(this.$refs.items.length); // already correct

A list ref keeps the [] in the attribute

html
<li data-ref="items"></li>
<li data-ref="items[]"></li>

config.refs: ['items[]'] matches data-ref="items[]" and nothing else. The mismatch gives a ref.mismatch warning naming both spellings.

The property name never carries the suffix: $refs.items, onItemsClick, @on('items[]', 'click').

Options

$options is read-only

Every property is a getter with no setter, so an assignment throws.

js
this.$options.open = true; 
this.$el.setAttribute('data-option-open', ''); 

Or keep a private field, when the value was never an input:

js
#isOpen = this.$options.open; 

A boolean option reads presence

The attribute's value is never read.

html
<div data-option-open="false"></div>
<!-- v3: false · v4: true -->

Turn one off by removing the attribute, or — for an option declared default: true — with the negated spelling:

html
<div data-component="Dialog" data-option-no-trap-focus></div>

Templates must write the attribute conditionally rather than interpolate a boolean into it.

Non-primitive defaults must be factories

js
options: {
  tween: { type: Object, default: { ease: 'linear' } }, 
  tween: { type: Object, default: () => ({ ease: 'linear' }) }, 
}

A literal gives an option.literal-default warning, and the value is then shared between instances.

Responsive options lose the list syntax

Every option is responsive with nothing to declare. withResponsiveOptions is gone, and so is the set spelling.

html
<div data-option-columns:xs:s="2"></div>
<div data-option-columns:xs="2"></div>

A suffix names one breakpoint and cascades upwards. An unknown suffix warns once per mount.

config.emits is a type now

js
static config = { name: 'Slider', emits: ['goto'] }; 
ts
class Slider extends Base<{ $emits: { goto: { index: number } } }> {} 

Nothing of it stays in the bundle.

Events

$emit() takes one payload object

js
this.$emit('slide', 1, 'left'); 
this.$emit('slide', { index: 1, direction: 'left' }); 

detail is that object, and an omitted payload leaves detail at the platform value null. A value that is not an object is refused by the type and reported as event.invalid-emit-payload.

Handler payloads are one object

js
onSliderItemClick(event, index) {} 
onSliderItemClick({ event, target, index }) {} 
  • a ref handler receives { event, target, index }, where target is the ref element, not event.target;
  • a child handler receives { event, target, payload }, where target is the instance;
  • a global handler receives { event, target }, with no payload and no index.

onWindow<Event> and onDocument<Event> are reserved prefixes

They match before children and refs. To reach a child named Window, use @on('Window', 'resize').

Lifecycle

updated() is removed

There is no third lifecycle notion. Use option<Name>Changed() for an option that chooses a resource, and watchAttributes() for an attribute the framework does not read.

mounted() can return its cleanup

js
mounted() {
  this.unsubscribe = useScroll().subscribe(this.onScroll); 
  return useScroll().subscribe(this.onScroll); 
}

unmounted() {
  this.unsubscribe(); 
}

Sync or async, a function or an array of functions. An async mounted() that resolves after the unmount runs its cleanup immediately.

terminated and the permanent state are gone

$unmount() leaves the instance on its element, so "do this once per element" is a plain field:

js
mounted() {
  if (this.hasLoaded) return;

  this.hasLoaded = true;
}

Loading and mounting

data-load becomes data-mount

Deferring the import and deferring the mount are one decision.

html
<div data-component="Map" data-load="visible"></div>
<div data-component="Map" data-mount="visible"></div>

The withMountWhen* decorators are deleted

v3v4
withMountWhenInView(Base)data-mount="visible" or config.mountStrategy
withMountOnMediaQuery(Base, '(min-width: 48rem)')data-mount="media:(min-width: 48rem)"
withMountWhenPrefersMotion(Base)data-mount="media:(prefers-reduced-motion: no-preference)"

A strategy constructs nothing; it decides when the registry calls the mount and unmount hooks. See Mount strategies.

The lazy import helpers are replaced by the manifest

importWhenVisible, importWhenIdle, importOnInteraction, importOnMediaQuery and importWhenPrefersMotion are gone. One entry carries both halves:

js
registerManifest({
  Map: { load: () => import('./Map.js'), mountStrategy: 'visible' },
});

readEagerTokens and <meta name="js-toolkit:eager"> are not ported — data-mount on the element says the same thing.

<tk-name> and arbitrary selectors are removed

Only plain and configured scoped data-component declarations are discovered. data-component still works on native elements.

Configuration and composition

withExtraConfig() is removed

$config merges along the prototype chain, so declare a class:

js
const Compact = withExtraConfig(Vendor, { options: { compact: Boolean } }); 
js
registerComponent(
  class extends Vendor {
    static config = { name: 'CompactVendor', options: { compact: Boolean } }; 
  }, 
); 

refs, options and components merge; scalar keys stay overridable by the most derived class.

withGroup is removed

Use provideRootContext() for page state, and createGroup() with provide/inject for a scoped set of peers. See Shared state.

defineFeatures() is removed

Breakpoints are set with setBreakpoints(). The attribute names and the prefix are not configurable.

Services

LoadService is not ported

Props are flat

The grouped objects are gone, and so are the derived fields.

js
scrolled({ y, changed, direction, max, progress }) {} 
scrolled({ y, deltaY, maxY, progressY, directionY, isScrolling }) {} 
  • lastX is x - deltaX; changedX is deltaX !== 0.
  • directionX and directionY are -1 | 0 | 1, one signed value that multiplies.
  • ResizeService keeps width, height, ratio and orientation, and drops breakpoints and activeBreakpoints.
  • DragService drops isGrabbing, hasInertia, target and props.MODES, fixes the dragTreshold spelling, and gains an idle mode.
  • PointerService uses pointer events only and follows one pointerId at a time.
  • Every prop field is readonly, and the props object belongs to its service. Use { ...props } to keep one.

Services are scoped to a target

js
useScroll(); // the document element
useWindowScroll(); // the same, named
useScroll(this.$refs.panel); // a region

useKey() takes a target too, which is what removes the hasFocus bookkeeping a document-only service forced on a consumer. Its named key booleans are resolved from KeyboardEvent.key rather than the deprecated keyCode, and triggered now counts repeats of one key.

A mixin never occupies a lifecycle hook

withRaf, withScroll and friends override $mount()/$unmount(), not mounted(). A class that mixes a service in and writes its own mounted() without super.mounted() still subscribes.

hook options are gone: one method name per mixin, and it is the name of the service. Any other target is an explicit subscribe() in mounted().

Scheduling and animation

One scheduler replaces three

domScheduler, the RafService loop, SmartQueue and the view-transition scheduler are one frame-aligned scheduler. this.$read() and this.$write() tie tasks to the instance.

afterWrite is removed. rAF callbacks run before style, layout and paint, so no phase inside the frame can read post-layout geometry. Measure in the next frame's read phase, or use a ResizeObserver.

tween and animate are not shipped

Time-based playback, stagger, sequencing, morphing and text splitting move to a separate ui-animation package, with Motion as declarative components and GSAP as a lifecycle-and-scoping decorator.

exit, layout and layoutId are not an engine's job — native view transitions solve them, and viewTransition() is in core.

What was promoted into core instead: transition with enterTransition and leaveTransition, the easing functions, spring() and smoothTo().

damp() takes the elapsed time

js
damp(current, target, 0.1); 
damp(current, target, 0.1, delta); 

factor is the fraction of the gap that closes per reference frame, which makes it stable for every value a caller can pass. Decay is expressed in time, not in frames.

scrollTo() splits in two

scrollPosition() measures and returns; scrollTo() calls it and moves. A carousel that asks which slide is nearest no longer has to scroll to find out.

Diagnostics

EVENTS.error, ToolkitErrorDetail and ToolkitErrorStage are removed with no alias:

js
document.addEventListener(EVENTS.error, …); 
document.addEventListener(EVENTS.diagnostic, …); 

The detail now carries severity, code, message, an optional component, and — for an error only — the original caught value as error. See Diagnostics.

What is not promoted to core

Action, SafeAction, Fetch, Transition and the Data* family stay out of core. Core keeps general primitives only; those components belong to @studiometa/ui.

Removal checklist

RemovedReplacement
$parent, $children, $root$closest(), $query(), $watchChildren()
createApp()registerComponent() / registerComponents()
$update()nothing — refs are live
updated()option<Name>Changed(), watchAttributes()
config.emitsthe $emits props type
withExtraConfig()a subclass
withGroupprovideRootContext(), createGroup()
withResponsiveOptionsnothing — every option is responsive
withMountWhenInView and friendsmount strategies
data-load, loadStrategydata-mount
the importWhen* helpersregisterManifest() entries
defineFeatures()setBreakpoints()
LoadService
domScheduler, SmartQueuedefaultScheduler
afterWritethe next frame's read, or a ResizeObserver
tween, animatethe ui-animation package
EVENTS.errorEVENTS.diagnostic
<tk-name>, arbitrary selectorsdata-component

MIT Licensed