Skip to content

v4: remaining work before the ui 2.0 rewrite #780

Description

@titouanmathis

Tracking issue for everything still outstanding on the v4 prototype.

Where v4 stands (reconciled 2026-08-18 against main at #856). Three rounds merged (#777, #778, #779), then the round this issue was opened for (#781#789), and then 54 more PRs, #790#846, which this body had fallen behind. Fifteen ui families are ported as feasibility tests — Accordion, Dialog, ScrollAnimation, Slider, Data*, Action, ClickOutside, InView, Track, and from #847 Fetch, LazyInclude, Prefetch, Cursor, Draggable, Carousel — and the gap list they produce, now 1–43 in migration/REPORT.md, is what drove this roadmap. That list is now closed apart from three entries, and the work since has come from reviewing the closures rather than from porting anything new.

Every section is closed. F2 landed in #860, which closes this issue — the v4 prototype's outstanding work is done.

Section E is closed by #847, the last feasibility round: every family this roadmap named is ported, and the ten gaps it filed are section K. Sections A, B, C, D, I and J are closed. What is left is F (two ui-level refactors), K (the port round's open gaps) and three decisions in G.

#847's review is where four of those gaps came from, and it closed three of them. swap() grew the axis its consumers asked for, @on learned to type its own handler, smoothTo() became usable by the components it was written for, and two families stopped writing to the DOM in the scheduler's read phase. #848 followed from the same review: interaction:page.

Then #849#856 closed the investigation round, and three of those PRs exist because a question found something the roadmap had not: removing $terminate() uncovered a live listener leak, making booleans presence-only turned three port fixtures from quietly-right into plainly-wrong, and the first lint rule found a scheduler-lane defect in ScrollAnimation that no test could fail on.

Section B is closed: #785 landed every correctness item, two of them by deciding the reported behaviour was the contract rather than a bug. D2, I5, I6, C8 and C9 landed with it.

Sizes: S ≈ one focused change, M ≈ needs design judgement, L ≈ a round of its own.

Gap numbers are reconciled. The branches of the #781#789 round each appended to REPORT.md's gap list independently and several numbered a gap 23. #783 reconciled the list on merge: main's gaps 23–32 kept their numbers because #784's prose cross-references them, and the responsive gap moved to 33. #847 appended 34–39. The list now runs 1–39, no hole, no duplicate.


A — Core blockers

  • A1 — onWindow<Event> / onDocument<Event> (gap 15) · S · PR feat(v4): resolve onWindow and onDocument handlers #781
    ClickOutside had no v4 form at all. Proved by porting it: four lines of body, and $emit() makes the announcement bubble where v3 hand-built a CustomEvent.
  • A2 — Suspend/resume a service subscription within a mount cycle (gap 1) · M
    DESIGN.md §7 promises no permanent rAF loop; that promise is false today on any page with a slider or scroll animation. Two of four early ports dropped withRaf for a hand-rolled start/stop.
    Update: the InView/Track port did not independently confirm this — that family hand-rolls for a different reason and never wants to suspend. The case rests on the earlier two families alone, so it is weaker than first thought.
  • A3 — $options writability + factory defaults · done
    buildDefault() treats a callable default as a factory. Superseded by B7: the literal copy this entry described is gone — core no longer repairs a literal object or array default, it warns. The contract is a primitive can be a default; any other type needs a factory function, enforced by the types for the TypeScript audience and by the warning for the no-build-step one.
  • A4 — $id on Base · S · PR [Feature] Add stable v4 component IDs #796moved to C3, and the move was reversed
    This entry said a property on every instance costs every component, while uid('Accordion') costs only its callers, so uid() should join the utils list instead. [Feature] Add stable v4 component IDs #796 decided the other way and shipped $id: a readonly <ComponentName>-<sequence>, resolved from the merged config.name, available to derived field initializers and stable across destroy and remount cycles. The port is the evidence — migration/utils/uid.ts is deleted and AccordionItem, SliderItem and now CarouselItem read $id. There is no uid() in src/utils/.
  • A5 — $watchChildren subclass matching (gap 18) · M · PR [Feature] Watch v4 child component subclasses #798downgraded, built after all
    It shipped as the class form the deferral had ruled out for the predicate: $watchChildren(ComponentClass) and @children(ComponentClass) match by instanceof, over one matcher shared by the initial DOM sweep and the lifecycle updates. Constructor types flow through to the collections and callbacks. No global registry was added, and no predicate overload was: the open-set case the deferral described is what instanceof already covers.

B — Correctness and latent bugs · closed by PR #785

  • B1 — $destroy() cancelled scheduler tasks after the cleanups (gap 5) · S
    The task set is swapped out and cancelled before the cleanups run, so a cleanup's $write() lands in a fresh set and survives.
  • B2 — gap 14 ($options must be a type alias, not an interface) · S
    Was still open after gap 22, which fixed how Options<T> is read rather than what BaseProps constrains. The failure is interface MyProps extends BaseProps; the intersection form already worked. Fixed by relaxing BaseProps.$options to object, whose Record<string, unknown> constraint rejected nothing anyway. $refs/$emits stay strict — their constraints reject something real.
  • B3 — Option definitions lost merge: true (gap 9) · won't fix
    Measured usage in ui is two call sites — Accordion/AccordionItem.ts:53 and Tabs/Tabs.ts:58 — both a styles option with a nested default meant to be partially overridden. Accordion is superseded by Disclosure, which does without it. merge does not come to v4; ui's remaining consumer (Tabs) is a ui-2.0 concern.
  • B4 — Codemod for data-ref="x[]" (gap 11) · no codemod needed — v3's spelling restored
    The 36 occurrences were never breakages: v4 was wrong, not the markup. A config.refs: ['dots[]'] definition selects [data-ref="dots[]"] again, as in v3 — the suffix is carried by the declaration and the attribute. One spelling only; the unsuffixed attribute is a different ref. A dev warning covers the mistake that is actually possible: a name[] definition finding nothing while data-ref="name" sits in the markup. Amended by C8: the derived forms are suffix-free ($refs.dots, onDotsClick()) but the decorator names the declaration@on('dots[]', 'click').
  • B5 — perTarget() keyed by the target alone and dropped its remaining arguments (gap 26) · S · was a live bug on main
    Now WeakMap<Target, Map<string, Service>>, keyed by JSON.stringify() over the remaining arguments, with a keyOf escape for arguments that do not serialise. Fixes useDrag(el, options) with no call-site change.
  • B6 — mountStrategy did not merge along the prototype chain (gap 24) · S
    resolveStrategy() calls resolveConfig(), now exported from Base.ts. Every subclass of a strategy-declaring component no longer falls back to eager.
  • B7 — Deep object/array option defaults are still shared · not a bug — the contract
    Both the deep and the shallow copy are gone. A primitive can be a default; any other type needs a factory function. A literal object or array now warns once per declaration, naming the component, the option and the fix. The type-level ban was verified real — TypedOptionDefinition types an Object/Array default as () => OptionValue<T> only — and the warning reaches the no-build-step audience it cannot. Nothing in src/, migration/ or demo/ was relying on the copy: zero declarations had to be converted.

C — Cheap capability wins

  • C1 — useDrag({ axis }) (gap 12) · S · PR [Feature] Add v4 drag axis and inertia controls #797
    axis: 'x' | 'y' | 'both' controls the filtered movement props and the owned touch-action; both is the old default. Consumer CSS still wins, and the prior inline value returns after final teardown.

  • C2 — useDrag({ inertia: false }) (gap 13) · S · PR [Feature] Add v4 drag axis and inertia controls #797
    Keeps the exact projected finalX/finalY on drop, completes through stop/idle, and starts no scheduler tick.

  • C3 — A utils port (gap 10) · M · PRs feat(v4): port the next batch of utils from v3 #819, chore(v4): empty migration/utils — core covers some, families own the rest #820, feat(v4): add deepmerge to the utils #821, refactor(v4)!: make deepmerge variadic #822, feat(v4): port getOffsetSizes, scrollTo and noop #824, feat(v4): port the resource loading helpers #828now including uid(), see A4
    ~530 lines were copied across four ports; the estimate was ~200 lines in core. What shipped is larger and organised by subject: maths, easings, timing, strings, is, dom, focus, transition, transform, history, load, random, noop, scrollTo, smoothTo, selectors, deepmerge, memo — each with a spec and its own subpath. migration/utils/ is empty and deleted; what survives lives beside the single family that calls it.
    Landed with it: memo() (feat(v4)!: give options a responsive form, derived on read #783), one function replacing v3's three (memo, memoize, cache) — single argument keyed by identity, WeakMap for object keys, lifetime owned by the caller through clear(). No maxAge: nothing in v4 goes stale on a clock. And deepmerge (feat(v4): add deepmerge to the utils #821, made variadic by refactor(v4)!: make deepmerge variadic #822), which is what Track had to carry as a dependency.

  • C10 — memo() over the hand-rolled caches in Base.ts · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853 — and only one of the two was a cache
    resolveConfig() goes through memo(). optionReaders does not, because it is not a cache: nothing computes it — buildOptions() writes it as a side effect while returning something else — so it became a private field, which is where an instance keeps its other state. Measured rather than assumed: memo() is ~15 ns/hit slower than the hand-rolled WeakMap, so this is consistency and five fewer lines, not speed. perTarget() is not a candidate — see “Deferred”. refPropertyName() was considered by feat(v4): C9 — restore v3's namespaced data-ref form #789 and rejected on measurement: one endsWith and one slice, once per ref per mount.

  • C4 — useScrollProgress(el, { offset }) · dropped, see “Deferred”

  • C5 — The keyframes interpolator, split from the player · dropped, see “Deferred”

  • C6 — A parameterised mount strategy (gap 23) · M · PR feat(v4): parameterize viewport mount margins #799
    Answered by the parameterized name, which is what media:<query> already proved: visible:<rootMargin> and in-view:<rootMargin> pass the exact suffix as IntersectionObserverInit.rootMargin, through the one applyMountStrategy() path, so config, data-mount and lazy manifest entries all get it. Bare visible:/in-view: are treated as bare rather than passing an empty margin.
    The scope is deliberately narrower than this entry asked for: margin only. No threshold, no root element, no JSON options, no idle timeout, no second attribute. That is the case ui documents; anything past it waits for a consumer.
    Extended by feat(v4): add the interaction:page mount strategy #848, on the same principle and from a review question: interaction was element-scoped and the docs did not say so, while the page-wide form — mount the deferred widget once the visit proves it has a user — had no spelling at all. interaction:page is that spelling, with one shared, captured listener set for the whole page rather than one per waiting element.

  • C7 — An intersection service (gap 25) · S
    withMountWhenInView is answered by data-mount="in-view" (60 lines → 0); withIntersectionObserver is answered by nothing. The port wrote useInView(el, init) in 20 lines.

  • C8 — @on takes a target value, not only a string (was G1) · S · PR feat(v4): let @on take its target as a value (C8) #787
    @on(window, 'load'), @on(document, 'click') and @on(Child, 'click'). No reserved string, so @on('Window', 'resize') still means a child named Window. window/document are matched by identity, so another realm's global is refused rather than bound elsewhere. An arbitrary EventTarget is refused: a decorator runs once at class definition with no instance and no document, so it could only be a module-scope value shared by every instance — that belongs in mounted().
    The string forms are not a fallback, they are the answer for a lazy child. A child declared () => import('./Child.js') exists to keep its chunk out of its parent's, so @on(Child, 'open') would import exactly what the thunk defers. @on('Child', 'open') imports nothing.
    A ref is named by its declaration: @on('dots[]', 'click'), while the derived forms stay suffix-free ($refs.dots, onDotsClick()). One spelling — @on('dots', …) does not reach a dots[] declaration. The mismatch cannot be a type error, since the decorator sees only a string and cannot read config.refs; it warns at bind time instead, and only when the other spelling matches a declared ref.

  • C9 — v3's namespaced data-ref="Component.name" form, restored · S · PR feat(v4): C9 — restore v3's namespaced data-ref form #789
    belongsTo() refuses a ref with any data-component between it and the root, so a ref nested in a child component cannot belong to an outer one. The namespaced form is the escape: data-ref="App.form" binds to the enclosing App past an intervening component. ui's three usages are all that shape — a ref wrapped in a presentational component.
    The namespace lives in the markup only, never in config.refs — and that is not a simplification, it is forced. FigureShopify declares refs: ['img'] once, inherited from AbstractFigure, and its templates write data-ref="img" three times and data-ref="FigureShopify.img" once: one declaration, two spellings, chosen per template by nesting depth. A namespace in the declaration would force one spelling on every template and make an inherited ref undeclarable, since a base cannot know its subclass's name. So @on never sees a namespace, and C8's rule is untouched: next and Slider.next are not two spellings of one thing, they are two questions — nearest owner versus named owner — answered into one property.
    Namespaced list spelling is Component.name[], the order v3's code produces. Two queries rather than a selector list, measured: 8.7 µs against 11.2 µs over a 25-element subtree, versus 8.0 µs for the plain single query — a selector list costs Chromium its single-attribute fast path. The ancestor check is token-aware (~=), so data-component="Slider Other" counts as a Slider; v3 compared the whole attribute and could not.
    Open follow-up: a config.refs: ['Slider.next'] declaration half-works silently — the property becomes $refs['Slider.next']. One line of dev warning would close it. Not ruled on.

D — Unmeasured subsystems

  • D1 — Autoload manifests · L · PR feat(v4): give the registry a lazy half with registerManifest() #782
    v3's 1,033 lines become +245 in registry.ts; ~260 lines are absorbed outright by the one observer and data-mount. The loadStrategy/mountStrategy split was dropped on evidence: ui's entries are per-package policy, and data-load appears once in the whole repo, in a docs page.
  • D2 — Responsive options · M · PR feat(v4)!: give options a responsive form, derived on read #783 ⚠️ breaking
    Derived on read, so read-only $options stays closed. Renames $optionChanged$optionsChanged.
    Decided 2026-08-14: it stays in core, unconditionally — the plugin seam is rejected, a basic feature belongs in the core graph. And the responsive: true opt-in is gone: every option is responsive, because an option should support data-option-<name>:<breakpoint> by being an option, not by naming itself.
    The cost is on the read path, not the observer. Widening attributeFilter from 44 to 300 names left mutation throughput flat within noise — Chromium does not scan it linearly. But the breakpoint cascade cost 4.70 µs per read against 0.052 µs for a plain attribute, essentially all of it eight MediaQueryList.matches reads. Memoising the active breakpoint for the length of a task brings it to 0.38 µs (12.3× faster, 7.4× off a plain attribute). The residual is the cascade walk itself — up to nine getAttribute() calls — which is the feature. See memo() under C3.
  • D3 — Non-bubbling child events (mouseenter / mouseleave) · M

E — Feasibility ports

  • E1 — Frame + Fetch · M · PR test(v4): port the six remaining ui families onto v4 #847
    Frame is not ported and will not exist in ui 2.0 — a product decision: Fetch does the same job and is easier to use. Fetch and FetchShopifySection are ported (397 → 410 code lines, +3 %, 60 specs). FetchShopifyPartial is skipped with its reason recorded: it rebuilds the whole fetch lifecycle around a dynamic import of an uninstalled preview package, and every core-relevant seam it exercises is already covered by FetchShopifySection.
    swap() finally had consumers, and they disagreed — which is the finding this entry was opened for. LazyInclude was covered completely; Fetch could not use it at all and copied 26 lines core already had. The axis between them was only whether the element itself is replaced, and naming it is what closed K3 in review: swap(target, content, { self }), after which Fetch's four-branch update is one call and the copy is deleted.
  • E2 — Carousel · L · PR test(v4): port the six remaining ui families onto v4 #847 — the largest coordinator in ui
    431 → 444 code lines (+3 %) over seven classes plus a context, and 206 → 157 (−24 %) for its withIndex/Indexable infrastructure. The distribution is the finding, not the total: AbstractCarouselChild −72 % and CarouselItem −67 %, both reappearing as +35 % on the coordinator that absorbed their geometry.
  • E3 — InView + Track · M · PR test(v4): port the InView and Track families onto v4 #784
  • E4 — Prefetch / LazyInclude · M · PR test(v4): port the six remaining ui families onto v4 #847Prefetch 87 → 82 (−6 %), LazyInclude 56 → 64 (+14 %)
  • E5 — Cursor / Draggable · M · PR test(v4): port the six remaining ui families onto v4 #847Cursor 121 → 107 (−12 %), Draggable 191 → 222 (+16 %)
    These two closed gap 1 by measurement, in the components the gap was found in: a spec counts requestAnimationFrame calls with a Cursor at rest and gets zero. See A2.

Fifteen of roughly forty-four ui families ported, and every family this roadmap named is done. The round is flat overall — 1489 → 1486 code lines — with 157 new specs.

F — ui-level refactors surfaced but never owned

  • F1 — Modal surface: reference-counted scroll lock · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853
    lockScroll(target?) counts its holders in the shared runtime: the first lock saves the inline value it found, the last release restores exactly that, and the release is idempotent so a surface calls it on close and on destroy. It fixed a live bug — a dialog opened from inside a drawer gave the page its scroll back under the still-open drawer — and a leak, since neither ui component released on destroy. <dialog>'s showModal() does not lock scroll, so the item was not obsolete. The focus half had already landed in feat(v4): port the next batch of utils from v3 #819.
  • F2 — Converge the two expression evaluators · S · PR refactor(v4): converge the two expression evaluators #860
    One compileExpression(argNames, body) in migration/expression.ts, replacing migration/Action/expression.ts's and migration/Data/expression.ts's separate caches and the uncached third call site in Fetch.parseResponse(), which recompiled on every response. The cache is two Map levels — argument list, then body — instead of a joined cacheKey string, which is what fixes Data's collision: getCallback() drops its group parameter entirely, since it was never part of the executed function's arguments.

G — Decisions, no code

  • G1 — The naming set · decided 2026-08-14, four answers:
    • config.components keeps its name and its object shape, and gains v3's dynamic import form — a value may be a class or a () => import('./Child.js'). That lets a manifest declare only the parent and leave its children's loading to it. Not a rename, so the open question closes; the import form is work, tracked as I5, and it is also what answers I2.
    • $watchChildren stays. $children(name, callbacks) is not adopted.
    • Announcement event namescomponent:mounted / component:destroyed stand for now.
    • config.use vs config.siblings — not planned. [Feature] Add support for sibling configuration #697 is not on this roadmap.
  • G2 — mountStrategy vocabulary · closed. The vocabulary shipped with its table and its stated visible vs in-view split (mount-strategies.ts), and withMountWhen* has no v4 existence to interact with — withMountWhenInView is answered by data-mount="in-view". Parameterising the vocabulary stays open as C6. Closes DESIGN.md open question 3.
  • G3 — Record the $emit cancelation decision (open question 2) · S
    Decided: defaultPrevented is a userland channel. Nothing framework-side reads it — DESIGN.md already states this for the lifecycle and negotiated events ("the step is announced, not proposed"), and the same rule now covers a userland $emit. All that remains is writing it into open question 2.
  • G4 — @on has no global form · moved to C8, and answered by taking the target as a value rather than a reserved string.
  • G5 — A subclass cannot narrow $emits · won't fix, see “Deferred”
  • G6 — with<Service> is a compile-time name · folded into J2, which is its answer.
  • G7 — $options writability (gap 2) · S · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — the one REPORT gap this roadmap never carried
    Ruled: read-only, forever. An option is an input, never a store. The surface was 9 assignments in 3 ui classes, only two of them genuine reconfiguration — and both are a toggleAttribute call, which is the idiom feat(v4): turn a boolean option off with data-option-no-<name> #849 documented. A setter cannot be made coherent: at a breakpoint it has no answer to which of nine spellings to write, and the write returns as a mutation record. $options is typed Readonly<Options<T>>, and $el, $id, $options and $refs are now non-writable properties of the instancereadonly for a reader with a build step, a property descriptor for everyone else. The lint half is K10's no-options-assignment.
  • G8 — Write gap 28's sentence into the docs · S · 682d182d, direct to main — and the sentence it asked for was wrong
    The ask was to write down "with<Service> is a compile-time name". Writing it showed the phrase is wrong twice: withRaf(Base) is an ordinary runtime call which returns a class, and the audience this framework puts first has no compile step for the phrase to describe. What is fixed is that one method is one subscription, under the name the service owns, decided by the class rather than by the markup — so a component whose subscriptions are one per markup declaration has no method to name and no fixed count, and subscribes by hand. DESIGN.md §8 states it with the escape as the intended path; REPORT.md had repeated the wrong phrase four times and all four are corrected.

I — Autoload follow-ups (from #782)

  • I1 — Manifest generation from a bundler glob · ~40 lines — ui has 98 components and generates manifests today; the obvious next layer
  • I2 — A lazy component drags its config.components family into one chunk · answered by I5 — a child declared as () => import(…) is its own chunk, so the family splits where the author says it splits
  • I5 — config.components accepts a dynamic import (from G1) · M · PR feat(v4): accept a dynamic import in config.components (I5) #786
    A value may be a class or a () => import('./Child.js'). The thunk is never called at registration: it becomes a lazy entry under its key in the manifest half of the registry, and scheduleFor()/scheduleLoad()/importComponent() handle it unchanged. The map key supplies the name, which is what makes the object shape load-bearing — a thunk cannot name itself until it resolves. A class is told from a thunk by the prototype chain, the same test resolveComponentClass() already uses; a class that does not extend Base is caught at registration through its non-writable prototype descriptor rather than throwing on an element much later.
    No mountStrategy field on an entry — that would put back the knob feat(v4): give the registry a lazy half with registerManifest() #782 dropped. Before the class loads the chain is data-mount > eager; after it registers, B6's merged config means a lazy child that subclasses inherits the strategy its base declared.
  • I6 — registerComponent() walked the own config.components, not the merged one · S · PR fix(v4): register the merged config.components (I6) #788 — B6's shape, for a different field
    registerFamily() now reads resolveConfig(…).components, so a subclass registers the family its base declared. The behaviour change reaches class children too, deliberately: $config already merges, so the family a subclass inherits is the family its instances have. Recursion needed no new guard — registerComponent() maps the name before walking the family, so a cycle closes on its second visit and returns silently, which is also why a base and its subclass registering one family raise no spurious warning. resolveConfig() merges the maps per key rather than taking the nearest, confirmed by test.
  • I3 — Two bundled copies are two registries · ~25 lines — a Symbol.for guard, and a v4-wide question (scheduler and services have it too), not an autoload one
  • I4 — No error-reporting convention · 8 lines once v4 has one at all
  • I7 — autoload.spec.ts flakes under parallel load · probably fixed by B6, not flaky
    lets the element data-mount win over the entry default failed intermittently before fix(v4): the correctness and latent-bug set from #780 (section B) #785, including 2/2 in CI on one branch while main was green — a ratio too consistent for a flake. It has not recurred since fix(v4): the correctness and latent-bug set from #780 (section B) #785 landed, across CI and local runs on four branches. fix(v4): the correctness and latent-bug set from #780 (section B) #785 rewrote mountStrategy resolution along the prototype chain (B6), which is exactly what that test exercises. Reopen if it returns.

J — The declarative attribute language (investigated 2026-08-14) · closed by #859

Four families implement the same shape — data-<ns>[-<subject>]:<qualifier>[.<part>…] — with four independent parsers. One of them is core's.

Family Example Qualifier set Mechanism Typo warnings
Responsive options (core, #783) data-option-columns:s finite (breakpoints) registered into attributeFilter yes
Action data-on:click.prevent.stop open (any DOM event) $watchAttributes no
Track data-track:scroll.throttle200 open (any DOM event) $watchAttributes no
Data data-bind:prop.value hybrid — 6 finite types, open names memoised, never rebinds no

Evidence this is one shape, not four similar ones:

  • Action.mounted() and AbstractTrack.mounted() are the same fifteen lines — scan $el.attributes, #bind(name, #parseAttribute(…)), $watchAttributes filtered on the prefix, release the Map on teardown — down to the same justifying comments. Written independently by two different ports of two different families.
  • ui carries the duplication today, not as a port artefact: Action/ActionEvent.ts:63 and Track/TrackEvent.ts:39 each implement split('.') over the same modifier vocabulary. Action's is prevent | stop | once | passive | capture | debounce; Track's is that set plus throttle — a superset, not a variant.
  • Data is the same family one generation behind: it memoises its bindings, so a data-bind:* rewritten in place never rebinds — the exact bug $watchAttributes was built to fix, which Action and Track both now consume.

Why four parsers is not carelessness: both mechanisms only became available this round. Filter registration existed for options; $watchAttributes landed two rounds ago. Nobody could have written a unified parser before now.

  • J1 — Settle the grammar · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859
    Ruled: the colon has one meaning — pick one member of the vocabulary the namespace declares — and the two readings this issue found are two kinds of namespace, not two meanings of the separator. A namespace is fixed (written in a module: data-component, data-on, data-bind) or generated (one per declared option, so columns owns data-option-columns); the colon after either picks one member. What falls out is a checkable invariant — at most one colon per attribute — pinned in attributes.spec.ts. Aligning options onto data-option:columns and dropping the prefix for data-columns were both weighed and refused; see DESIGN.md §3 and RATIONALE.md for why. RESPONSIVE_SEPARATOR is renamed QUALIFIER_SEPARATOR.
  • J2 — An attribute-namespace primitive in core · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859
    watchAttributeNamespace(el, namespace, bind, options?) in src/attribute-namespaces.ts. Absorbs the identical block from Action and AbstractTrack, gives DataBind the live rebinding it lacked, and an optional finite qualifier vocabulary now warns once (attribute.unknown-qualifier) instead of an attribute silently doing nothing. One correction to this ask's own framing: the mechanism follows from whether the whole name is enumerable, not whether the qualifier is finite — data-bind's six binding types are finite while the name after the dot is not, so it is watched despite the finite head; a declared option's names are attribute × breakpoint, which is enumerable, so responsive options keep their own registration rather than being routed through a shared selector with one caller. Absorbs G6, as scoped.
  • J3 — One modifier parser in ui · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859
    migration/event-modifiers.ts: one frozen MODIFIERS object and one parseEventDefinition(), consumed by ActionEvent and TrackEvent. The per-family default delay (Action 100 ms, Track 300 ms) is what actually differed, so the parser reports only the delay an author wrote and each family keeps its own fallback; an unknown modifier now warns instead of binding silently. The Modifier/TrackModifier barrel collision is gone.

The split: core owns when to re-parse and how the attribute is observed; ui owns what the string means.

Sizing: the affected ui files are DataBind 522, TrackEvent 283, AbstractTrack 240, ActionEvent 235, Action 90 — but the overlapping shape is only ~40–60 lines each. This is a consolidation of roughly 150 duplicated lines, not a rewrite.

Sequencing: #783 added the fourth parser to core, and deciding J1 before it merged would have been cheaper than unifying later across a shipped API. That moment passed, and the surface has grown since — #842 gave core's own attribute names one owner (src/attributes.ts, a leaf module that imports nothing from core), and #844 added multiple option types. Read attributes.ts as prior art, not as J2: it owns which names core spells, not how a namespace is parsed and observed, and it is the fifth parser's foundation rather than the primitive that removes it. #859 is where this got settled: J1's ruling, J2's watchAttributeNamespace(), and J3's event-modifiers.ts landed together, closing the fifth-parser risk this paragraph describes.

K — From the six-family port round and its review (#847, #848), gaps 34–43

The last feasibility round, and the first whose findings are mostly about what a component author gets wrong silently rather than about a missing primitive. Most of them fail with no warning, no type error and no exception.

Six came from the port (34–39) and four more from reviewing it (40–43). All ten are closed — six by growing the axis a written consumer asked for, two by ruling that the limit is the contract, and two by checking what a type cannot. Nothing here is open.

  • K1 — data-option-no-<name> (gap 34) · S · PR feat(v4): turn a boolean option off with data-option-no-<name> #849 — implemented, not refused
    The investigation recommended refusing the prefix and warning. It was implemented instead, and the implementation is small because the negation resolves to a raw value the boolean rule already reads: one cascade, one parsing path, and a scoped form (data-option-no-x:s) for free. Only an option which can hold false gets one. And booleans became presence-only with itdata-option-open="false" reads true, as disabled does on the platform — which caught three port fixtures stringifying a boolean into an attribute. The noSort collision turned out not to exist: that option's own negation is data-option-no-no-sort.
  • K2 — $terminate() does not survive a DOM move (gap 35) · M · PRs fix(v4): the two lifecycle rulings — service mixins and $terminate() #850, refactor(v4)!: remove the termination lifecycle notion #852
    Ruled, then removed. v4 already preserves instance identity across a move, so "do this once per element" is a field: LazyInclude keeps one and the red spec went green with no framework change. Then the question does $terminate() still make sense? found that its only production caller anywhere was the registry, and all five ui usages were the same misuse — so the termination notion is gone (refactor(v4)!: remove the termination lifecycle notion #852): no $terminate(), no terminated(), no $isTerminated. Removing it uncovered a live leak it had been hiding: $watchChildren added one document listener per watcher, released only on termination, so 20 watchers meant 20 never-released listeners and every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it.
  • K3 — swap() can only replace a target's children (gap 36) · M · closed in test(v4): port the six remaining ui families onto v4 #847's review
    SWAP_MODES were Fetch's four modes exactly and adoptScripts() was ui's helper in substance, yet Fetch could use neither, because replace was replaceChildren() and morph passed childrenOnly: true. self is the axis, and it is an option rather than a fifth mode: mode says how the content is applied, self says what is replaced. With it an Element content is the replacement — reading it as a container is what dropped the attributes the option exists to carry — and the additive modes warn (swap.self-ignored) instead of ignoring the ask quietly. Script adoption follows whatever ends up in the document, so it covers a replacement that is itself a <script>. Fetch.updateDOM() is one call and Fetch/utils.ts is deleted.
  • K4 — scrollTo() has no alignment (gap 37) · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853
    align: 'start' | 'center' | 'end', or one per axis, plus scrollPosition() — the measuring half, because a carousel asks which slide is nearest three times for every time it travels. The names are physical (x/y) rather than the platform's inline/block, since nothing here maps a writing mode. The dependency was measured and refused: compute-scroll-into-view walks every scrolling ancestor, which is what v4's single rootElement contract declines and what ui already cancels with boundary, and its own source implements neither writing modes nor scroll-padding — a real delta of ~20 lines.
  • K5 — A service mixin's mounted() was silently skipped (gap 38) · S · PR fix(v4): the two lifecycle rulings — service mixins and $terminate() #850
    The ask was a diagnostic; the trap was removed instead. A mixin binds from $mount()/$destroy() now — the framework's own methods, where $terminate() already lived — so mounted(), destroyed() and terminated() belong to the component author and there is nothing to chain. The whole suite passed before a spec was touched, and the eleven ui files which mix a service in without chaining will work unchanged. A userland mixin which puts its work in mounted() still needs the chain, which is the rule as DESIGN.md §8 now states it.
  • K6 — A mixin's target resolver is typed against Base (gap 39) · S · PR fix(v4): report a mixin target resolver which comes back with nothing #858 — the typing stands, the silence does not
    A mixin is applied while its class's extends clause is still being evaluated, so the resolver cannot be typed against the class being defined; the call site asserts a shape, as v3 does with @ts-expect-error. That limit is documented rather than worked around. What is fixed is what the assertion hid: a stale one resolves to undefined, and every service with a default target takes over — useResize() defaults to the document element, so a renamed ref left the component observing the page and looking like it worked, measured rather than assumed. A caller's resolver returning nothing now reports service.missing-target and starts no subscription; withRaf, whose own target is nothing by design, is untouched. A ref-name form was built and rejected — it removed the cast but added a second spelling of one option, and left the same silence for every hand-written resolver.
  • K7 — @on(type) typed its handler as the base Event (gap 40) · S · closed in test(v4): port the six remaining ui families onto v4 #847's review
    The magic-name form always allowed onClick(event: MouseEvent); the decorator's one-argument overload took (event: Event) => void, which contravariance rejects a narrower parameter for — so the sugar was the stricter of two spellings of one binding. Found the moment the ported families were converted to decorators, in three handlers across two families. The overload now maps a name in HTMLElementEventMap to its platform type, and a name outside it — a component event, whose detail only its emitter knows — infers the type the handler declares. Types only.
  • K8 — A @read/@write method cannot be overridden by a subclass (gap 41) · S · PR docs(v4): rule that @read and @write are leaf-method sugar #857 — ruled, not built
    A phase decorator returns a wrapper, and a wrapper is a property of that class: a subclass override replaces it, so the base's scheduling disappears and the body runs in the caller's phase. The ruling is that the phase belongs to the call site@read/@write are leaf-method sugar, and a template method schedules where the call is made, which is what AbstractCarouselChild already writes. The alternative was refused: dispatching through an indirection a subclass cannot replace would make a decorator's behaviour depend on inheritance depth, which nothing else in v4 does. It cannot be a lint rule either — base and subclass live in different files, and the plugin from feat(eslint-plugin): add the v4 lint rules and a v4 config #856 sees one file at a time. One spec pins it.
  • K9 — smoothTo() has no consumer in fifteen ports (gap 42) · M · closed in test(v4): port the six remaining ui families onto v4 #847's review, except one axis
    Four ported components hand-rolled the loop the helper exists to own, for two reasons. damping was captured at creation while every consumer's factor is a live option — and Cursor's scale factor also depends on the direction of travel; both are answered by damping taking a function, read per frame and per channel. The helper was scalar, so a position was two instances, two callbacks and two settle states; answered by a record of named channels — not { x, y }, since a component smooths a scale or a progress as readily as a coordinate — on one subscription, one settled state and one subscriber call. jump() and a per-mode precision default fell out of converting the consumer. Cursor consumes all of it (108 → 100 lines, its mixin and hook gone). Still open, and deliberately unbuilt: Draggable steps its damping from a drag event with a nominal frame, and the helper owns its clock. One hypothetical consumer is not evidence for a hand-stepped variant.
  • K10 — A frame subscriber runs in the read phase (gap 43) · M · PR feat(eslint-plugin): add the v4 lint rules and a v4 config #856
    Six rules ship in @studiometa/eslint-plugin-js-toolkit, with a configs.v4 and a packages/v4/** override — without which none of them run anywhere. oxlint already loads that ESLint plugin as a JS plugin, so there was no second implementation to write; the constraint is that a JS plugin gets no type information, so every rule is type-free. no-write-in-read-phase is proven rather than asserted: it reports both historical defects on their pre-fix files and is silent on the fixed ones. It also found a real defect on its way in — four ScrollAnimation sites used the global scheduler because a comment claimed $destroy() cancels tasks after the cleanups, when it cancels them before. The others: no-options-assignment, prefer-instance-scheduler, option-default-factory, no-conflicting-negated-option, and a v4 mode on the deprecated-properties rule.

L — What reviewing the closures produced (not on this roadmap when it started)

Every entry here exists because a question was asked about work that was already "done". None of it was planned, and three of the four are defects the test suite could not see.

  • The termination lifecycle is gone · PR refactor(v4)!: remove the termination lifecycle notion #852$terminate(), terminated(), $isTerminated and the terminate-callback list. Asking does it still make sense? found that the registry was its only production caller and all five ui usages meant "my work is done", which K2 had just ruled it does not mean.
  • $watchChildren leaked a document listener per watcher · PR refactor(v4)!: remove the termination lifecycle notion #852 — measured at 20 watchers → 20 listeners added, 0 removed. Element removal calls $destroy(), and only $terminate() released the listener, so every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it, with the owner holding its watchers strongly — otherwise the watcher is collected while its owner still lives.
  • A boolean option reads presence, not value · PR feat(v4): turn a boolean option off with data-option-no-<name> #849data-option-open="false" is true, as disabled is on the platform. It caught three port fixtures stringifying a boolean into an attribute, all of them quietly right under the old rule.
  • $el, $id, $options and $refs are fixed properties · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — non-writable, so an assignment throws rather than replacing what the whole framework reads. A get-only accessor was tried and measured instead: ~20 % slower on the five-thousand-component mount benchmark, since $el is read on every handler bind and ref query, so the data property stays. The experiment left one improvement behind — DataBind is generic in its props, so a subclass narrows through the type parameter rather than redeclaring a member.
  • LazyInclude remembers a load only when it succeeded · PR fix(v4): retry a failed LazyInclude on the next mount #851always fires from the request's finally, so a failed fetch had been marking the element as loaded. v3 has the same defect for the same reason; this is the port's one deliberate departure.
  • interaction:page · PR feat(v4): add the interaction:page mount strategy #848 — the page-wide scope of the interaction mount strategy, from asking what interaction was bound to. One shared, captured listener set for the whole page rather than one per waiting element.

Landed outside this roadmap

Recorded so this issue is not read as the whole picture. Between #790 and #846, core gained: the storage layer with six adapters (#818, #841), createGroup() (#826), the mutation service (#825), the key service (#846), responsive component declarations (#794), the unified diagnostic protocol (#800, #815), the cross-copy shared runtime (#801), the standalone helper splitwatchAttributes(), context subscriptions, UI helpers (#811, #812, #813), manifest generation (#795), the in-view and scroll-progress services (#792, #793), element-relative pointer coordinates (#827), resource loading helpers (#828), $id (#796), instances keyed by symbol (#831), mount benchmarks against v3 (#829, #830), type-aware linting (#838), and ten correctness fixes (#802#807, #832, #835, #839, #840). DESIGN.md was split into a spec and a RATIONALE.md.


Deferred / rejected

  • $watchChildren subclass predicate (was A5, gap 18) — no longer deferred: built in [Feature] Watch v4 child component subclasses #798, as the class form rather than the predicate. See A5. The reasoning kept here for the record: config.name is the identifier, and the family that raised it (Data*) did not end up using $watchChildren for membership at all; the closed-set need would have been string[], and the open-set need is what instanceof turned out to answer.
  • data-load shim — refused during the autoload work; the two-strategy split it belongs to was dropped on evidence.
  • A subclass narrowing $emits (was G5, gap 27). Won't fix, and it should not be fixed: narrowing is unsound. A Slider-typed reference may emit slide, so a subclass promising less breaks the contract its own base type advertises. The need usually behind the report is widening, which a class generic in its props already covers — class Slider<P extends SliderProps = SliderProps> extends Base<P> — at the price of a type parameter on every component meant to be extended.
  • Folding perTarget() onto memo(), and multi-argument memo(). perTarget() stays hand-rolled. Its second level keys by valueJSON.stringify() over the arguments — because the arguments are fresh object literals: useInView(el, { threshold: 0.5 }) allocates a new object on every call, so identity keying would miss every time and hand each caller its own service, which is worse than gap 26 was. Making memo() variadic does not help: a trie keys each position by identity, which is exactly the wrong equality here. Only a keyOf/cacheKey option would fit, and that is the axis memo()'s design rejected on evidence. Revisit if a caller appears that keys on two genuine identities — an (element, class) pair, say.
  • useScrollProgress(el, { offset }) and the keyframes interpolator (were C4 and C5). The next version of @studiometa/ui ships a @studiometa/ui-motion package that owns scroll-linked animation, so both lose their consumer. C4 already rested on ScrollAnimation alone — the InView/Track port gave it no support, Track being an analytics component rather than a scroll-driven one — and that one consumer is exactly what moves out of core's reach. Revisit only if a family outside motion asks for either.

Suggested order. Everything is done.

  1. J1, then J2/J3done, feat(v4): settle the attribute grammar, and unify its four parsers #859.
  2. F2done, refactor(v4): converge the two expression evaluators #860.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions