Skip to content

feat(v4): port ten simple ui families and fill the FetchShopifyPartial gap - #861

Merged
titouanmathis merged 24 commits into
mainfrom
feat/v4-migration-simple-families
Aug 24, 2026
Merged

feat(v4): port ten simple ui families and fill the FetchShopifyPartial gap#861
titouanmathis merged 24 commits into
mainfrom
feat/v4-migration-simple-families

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Continues the @studiometa/ui → v4 migration feasibility test (packages/v4/migration/, see REPORT.md) with ten component families that needed no design deliberation, plus the one gap the Fetch port had deliberately left open:

  • Sentinel, Sticky, Hoverable
  • ScrollTo (renamed from AnchorScrollTo)
  • the AnchorNav family
  • the Menu family
  • Timer / TimerProgress
  • Toast / Toaster
  • the Figure family
  • the FigureVideo family
  • FetchShopifyPartial

The round was picked as the one needing no design deliberation and produced two findings anyway, neither visible from the component list.

withTransition restored (gap 45)

Transition had been ported as a non-generic Base subclass, so class X<T> extends Transition<Y & T> did not type-check — and four families needed exactly that, each reimplementing the same state/target/enter/leave/toggle block. §4b had collapsed v3's mixin into the component on the finding that its body used this only to read two options; that held at one consumer and not at four, because what a consumer needs is the state machine around those two calls.

So v3's structure is back: withTransition is v4's first non-service mixin, Transition is withTransition(Base) again, both it and ViewTransition are generic, and the four consumers lost 138 net lines. AnchorNavLink is once more literally withTransition(ScrollTo). A new transitionOptions hook is what lets MenuList force enterKeep/leaveKeep in four lines — v3 did it by overriding the $options getter, which gap 2 ruled out.

Two bugs the tooling caught

  • data-option-x="false" reads as true in v4 (a boolean option's presence is its value). Hit in Toaster's sticky-toast branch, fixed to the negated attribute name, and audited across every family in the round. This is gap 34 arriving at the exact call site it predicts.
  • js-toolkit(no-write-in-read-phase) fired twice on Sticky — a third instance of gap 43, in scrolled() rather than ticked(), which the earlier thirteen-family audit had not thought to check. The scroll service emits from inside defaultScheduler.read(), so hide()/show() were interleaving classList writes into the read phase. The DOM half moved to an applyVisibility() marked @write, keeping isVisible synchronous.

Test plan

  • 99 new browser-mode specs (Vitest browser mode, Chromium)
  • Full packages/v4 suite green: 1512 tests, 112 files
  • tsc --noEmit clean (both project tsconfigs)
  • oxlint --type-aware packages/v4 back to main's baseline (one pre-existing warning in src/context.ts)
  • oxfmt --check clean

🤖 Generated with Claude Code

https://claude.ai/code/session_019vGCvbrSfjBKFzMHSiu9wg

Sentinel forwards the raw IntersectionObserverEntry via `withInView`,
unlike InView's collapsed in/out boolean. Sticky needs the entry's
boundingClientRect to tell "scrolled above the viewport" apart from
"scrolled below it".
The static `Set<Sticky>` instance registry v3 kept in sync by hand from
mounted()/destroyed() is gone: `getInstances('Sticky')` already answers
it live. Sizing the sentinel moved from mounted() to $watchChildren's
`added` callback, since a v4 mount carries no ordering guarantee and
the sentinel may not exist yet on the first cycle.
withRelativePointer's per-target progress maps onto withPointer's
ElementPointerProps.relativeProgress{X,Y} directly, which already
carries the outside-the-box range v1's contained/clamp logic needs.
v4's scrollTo() is a no-op on a missing target rather than throwing,
so the existence check that decided whether to preventDefault() moves
into the component instead of a try/catch around the call.
AnchorNavTarget's mount/unmount replaces withMountWhenInView with the
in-view mount strategy. AnchorNav no longer listens for the target's
mounted/destroyed lifecycle events under their plain v3 names — v4
dispatches those under a namespaced type magic-name delegation can't
bind to — and reacts through $watchChildren's added/removed callbacks
instead, which already fire on exactly that transition.

AnchorNavLink extends the newly-ported ScrollTo and calls the same
enterTransition/leaveTransition utilities Transition itself calls,
since v3's withTransition mixin has no v4 equivalent to mix onto an
unrelated base class anymore.
$closest('Menu') replaces getClosestParent(target, this.constructor)
for both the menuBtn/menuList getters and the click/hover guards, since
$watchChildren collects every matching descendant regardless of depth
and a nested submenu's own button/list would otherwise match too.

Menu no longer destroys itself in mounted() when a required child is
missing — v4 gives no ordering guarantee for child vs. parent mount, so
the button/list wiring moves to $watchChildren's added callback, and a
Menu with no list is inert rather than a hard failure.

MenuList implements Transitionable directly instead of extending the
ported Transition class: the latter is a plain, non-generic Base
subclass, and MenuList already overrides every one of its methods to
force enterKeep/leaveKeep true (the v3 $options-getter override this
needed has no v4 equivalent, since $options is a read-only own
property with no override point).

keyed() maps onto withKey's KeyProps one for one. The nextTick-deferred
close on mouseleave uses defaultScheduler.background(), the documented
v4 replacement.
Timer's internal timing fields stay plain fields rather than #private:
TimerProgress reads them from a subclass, and a JS private field is
invisible to a subclass entirely — there is no v4 equivalent of
"protected". Positional array-detail dispatch (__dispatch(name,
...detail)) becomes a named payload object, matching $emit's contract.

withRaf(Timer, { manual: true }) replaces v3's $services.disable
('ticked') workaround for the RafService auto-enabling on any class
that declares ticked() — manual mode never auto-starts, so there is
nothing left to neutralize in mounted().
A real finding along the way: a boolean option's DOM presence is its
value regardless of the string written — data-option-x="false" reads
true — so turning off a true-default option (Toast's autostart) takes
the negated attribute name (data-option-no-autostart), not ="false".
Fixed in Toaster's sticky-toast branch and audited across every other
family this session for the same mistake.

$emit only takes one payload object, so Toast/Toaster's positional
v3 emits (__dispatch(name, ...detail), $emit('show', toast, message,
type)) become named payloads, the same adaptation Timer needed.

Tests poll for a viewTransition()-driven DOM mutation rather than
trusting settle() or a fixed wait: the scheduler's write task that
flushes the transition queue returns as soon as
document.startViewTransition(...).finished is requested, not once it
settles, and a real headless compositor can take longer than usual to
finish one.
AbstractFigure implements Transitionable directly on Base rather than
extending the ported Transition class, the same reason MenuList does:
Transition is a plain, non-generic Base subclass, and this hierarchy
(AbstractFigure -> AbstractFigureDynamic -> FigureShopify/FigureTwicpics)
needs generic prop threading through four levels.

Figure drops v3's onLoad() { $terminate() }: v4 has no termination (the
LazyInclude port hit the same gap), and none is needed here either —
AbstractFigure.mounted() only loads when src !== this.src, which is
already false once loaded, so a later remount is a no-op on its own.

AbstractFigureDynamic gains resized() through withResize(AbstractFigure),
since v4 requires a mixin for a service hook v3 auto-wired from the
method's mere presence.
FigureVideo implements Transitionable directly on Base, same as
AbstractFigure and MenuList. Unlike Figure, it has no naturally
idempotent load check (load() unconditionally reassigns every source),
so v3's onLoad() { $terminate() } becomes a load-bearing hasLoaded
flag rather than documentation of an already-idempotent path.

FigureVideoTwicpics's onLoad() override was an empty no-op cancelling
that termination so it could still reload on resize; with no
termination to cancel, there is nothing to override.
Adapts Fetch to Shopify's @shopify/partial-rendering API, falling back
to the base id-based swap when no partials are configured, the request
can't be expressed through the partials transport (non-GET, a body, an
unsupported RequestInit field, a non-internal header), or the preview
package fails to resolve.

Reuses mergeRequestInit and the module-constant FETCH_EVENTS/HEADER_NAMES
the Fetch port already carved out for exactly this consumer. The one
static that stays a static (PARTIALS_MODULE / loadPartialsModule) is the
one actually meant to be overridden, by a test or a subclass — unlike
FETCH_EVENTS, which nothing in ui ever replaces.
Adds §16 for the ten families and the FetchShopifyPartial gap fill,
crediting each already-established primitive they confirmed
(getInstances, $watchChildren's added/removed, $closest) rather than
re-deriving them, and files the one genuinely new finding — Transition
ported as a non-generic class forces MenuList/Figure/FigureVideo to
implement Transitionable directly instead of extending it — as gap 45.

Updates the report's family table and opening test counts (99 new
specs this round, 489 in migration/, 1512 across the whole packages/v4
suite) to match.
Timer.spec.ts, TimerProgress.spec.ts and AnchorNav.spec.ts were fixed
in the working tree when gap 34 (data-option-x="false" reads true) was
caught during the Toast/Toaster port, but the fix only made it into
the Toaster commit — these three files kept the pre-fix content in
history despite every test run since passing against the corrected
working tree. No behavior change: data-option-no-autostart replaces
the no-op data-option-autostart="false", and the misleading
data-option-leave-keep="false" (also a no-op, on an assertion it
never affected) is dropped from AnchorNav.spec.ts.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Export size

Bundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped.

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
reportDiagnostic 329 B +329 B (+100.0%)
warn 321 B +321 B (+100.0%)
(barrel) 22.67 kB +56 B (+0.2%)
on 8.94 kB +33 B (+0.4%)
Base 8.59 kB +29 B (+0.3%)
registerComponent 11.27 kB +24 B (+0.2%)
registerManifest 11.33 kB +24 B (+0.2%)
component 11.66 kB +22 B (+0.2%)
registerComponents 11.28 kB +19 B (+0.2%)
withScroll 3.34 kB +2 B (+0.1%)
useDrag 3.36 kB +1 B (+0.0%)
createServiceMixin 1017 B -1 B (-0.1%)
withResize 2.11 kB -1 B (-0.0%)
Unchanged (382)

@studiometa/js-toolkit

Export Size (gzip) Diff
(barrel) 17.48 kB
AbstractService 598 B
Base 9.1 kB
ComponentLoader 2.31 kB
DEFAULT_DIAGNOSTIC_PREFIX 102 B
DragService 2.02 kB
IDLE_TIMEOUT 57 B
KeyService 935 B
LoadService 666 B
MutationService 849 B
PointerService 1.13 kB
RafService 1020 B
ResizeService 1.12 kB
ScrollService 1.36 kB
VISIBLE_ROOT_MARGIN 72 B
autoload 2.4 kB
closestComponent 419 B
composeManifests 119 B
createApp 996 B
defineFeatures 326 B
defineManifest 512 B
fromMetaGlob 228 B
fromWebpackContext 131 B
getClosestParent 197 B
getDirectChildren 202 B
getInstanceFromElement 125 B
getInstances 187 B
getScopedGroups 104 B
importOnInteraction 926 B
importOnMediaQuery 243 B
importWhenIdle 225 B
importWhenPrefersMotion 271 B
importWhenVisible 935 B
isDirectChild 218 B
logTree 551 B
queryComponent 594 B
queryComponentAll 601 B
readEagerTokens 201 B
registerComponent 305 B
registerComponents 356 B
registerManifest 2.87 kB
registerManifests 2.89 kB
useDrag 2.05 kB
useKey 943 B
useLoad 676 B
useMutation 876 B
usePointer 1.15 kB
useRaf 1 kB
useResize 1.13 kB
useScroll 1.36 kB
utils 10.08 kB
utils/Queue 291 B
utils/SmartQueue 473 B
utils/addClass 240 B
utils/addStyle 239 B
utils/animate 3.34 kB
utils/boundingRectToCircle 206 B
utils/cache 208 B
utils/camelCase 405 B
utils/clamp 98 B
utils/clamp01 114 B
utils/collideCircleCircle 129 B
utils/collideCircleRect 192 B
utils/collidePointCircle 128 B
utils/collidePointRect 122 B
utils/collideRectRect 128 B
utils/createEaseInOut 123 B
utils/createEaseOut 91 B
utils/createElement 635 B
utils/createLocalStorage 1.32 kB
utils/createLocalStorageProvider 296 B
utils/createMemoryStorageProvider 174 B
utils/createNoopProvider 128 B
utils/createRange 115 B
utils/createSessionStorage 1.32 kB
utils/createSessionStorageProvider 288 B
utils/createStorage 1.3 kB
utils/createUrlSearchParamsInHashProvider 461 B
utils/createUrlSearchParamsInHashStorage 1.35 kB
utils/createUrlSearchParamsProvider 429 B
utils/createUrlSearchParamsStorage 1.34 kB
utils/damp 106 B
utils/dashCase 404 B
utils/debounce 122 B
utils/domScheduler 310 B
utils/ease 519 B
utils/easeInCirc 285 B
utils/easeInCubic 287 B
utils/easeInExpo 286 B
utils/easeInOutCirc 288 B
utils/easeInOutCubic 289 B
utils/easeInOutExpo 288 B
utils/easeInOutQuad 288 B
utils/easeInOutQuart 289 B
utils/easeInOutQuint 289 B
utils/easeInOutSine 288 B
utils/easeInQuad 285 B
utils/easeInQuart 286 B
utils/easeInQuint 286 B
utils/easeInSine 285 B
utils/easeLinear 77 B
utils/easeOutCirc 286 B
utils/easeOutCubic 288 B
utils/easeOutExpo 286 B
utils/easeOutQuad 286 B
utils/easeOutQuart 286 B
utils/easeOutQuint 286 B
utils/easeOutSine 286 B
utils/endsWith 128 B
utils/fold 168 B
utils/getAncestorWhere 123 B
utils/getAncestorWhereUntil 148 B
utils/getComponentResolver 140 B
utils/getOffsetSizes 194 B
utils/hasWindow 88 B
utils/historyPush 524 B
utils/historyReplace 526 B
utils/inertiaFinalValue 169 B
utils/isArray 63 B
utils/isBoolean 78 B
utils/isDefined 75 B
utils/isDev 78 B
utils/isEmpty 206 B
utils/isEmptyString 108 B
utils/isFunction 79 B
utils/isNull 68 B
utils/isNumber 91 B
utils/isObject 108 B
utils/isString 77 B
utils/keyCodes 122 B
utils/lerp 84 B
utils/loadElement 220 B
utils/loadIframe 241 B
utils/loadImage 241 B
utils/loadLink 237 B
utils/loadScript 251 B
utils/localStorageProvider 839 B
utils/lowerCase 404 B
utils/map 93 B
utils/matrix 136 B
utils/mean 126 B
utils/memo 130 B
utils/memoize 228 B
utils/memoryStorageProvider 843 B
utils/nextFrame 179 B
utils/nextMicrotask 133 B
utils/nextTick 148 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 322 B
utils/pascalCase 407 B
utils/random 93 B
utils/randomInt 113 B
utils/randomItem 234 B
utils/removeClass 242 B
utils/removeStyle 243 B
utils/round 95 B
utils/saveActiveElement 92 B
utils/scrollTo 2.31 kB
utils/sessionStorageProvider 838 B
utils/smoothTo 476 B
utils/snakeCase 406 B
utils/spring 154 B
utils/startsWith 125 B
utils/throttle 125 B
utils/toggleClass 242 B
utils/transform 347 B
utils/transition 1010 B
utils/trapFocus 441 B
utils/tween 1.72 kB
utils/untrapFocus 120 B
utils/upperCase 404 B
utils/urlSearchParamsInHashProvider 845 B
utils/urlSearchParamsProvider 839 B
utils/useScheduler 309 B
utils/wait 103 B
utils/withLeadingCharacters 135 B
utils/withLeadingSlash 142 B
utils/withTrailingCharacters 135 B
utils/withTrailingSlash 142 B
utils/withoutLeadingCharacters 122 B
utils/withoutLeadingCharactersRecursive 165 B
utils/withoutLeadingSlash 133 B
utils/withoutTrailingCharacters 122 B
utils/withoutTrailingCharactersRecursive 165 B
utils/withoutTrailingSlash 133 B
utils/wrap 122 B
version 56 B
withBreakpointManager 1.54 kB
withBreakpointObserver 1.71 kB
withDrag 2.18 kB
withExtraConfig 163 B
withFreezedOptions 187 B
withGroup 455 B
withIntersectionObserver 303 B
withMountOnMediaQuery 393 B
withMountWhenInView 347 B
withMountWhenPrefersMotion 431 B
withMutation 1010 B
withName 109 B
withRelativePointer 1.29 kB
withResponsiveOptions 2.4 kB
withScrolledInView 3.05 kB

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
BREAKPOINTS 778 B
DIAGNOSTICS 714 B
DRAG_MODES 162 B
EVENTS 155 B
MOUNT_ATTRIBUTE 69 B
SWAP_MODES 129 B
children 244 B
createContext 472 B
createFallbackProvider 1.33 kB
createGroup 1.07 kB
createLocalStorage 2.33 kB
createMemoryStorageProvider 1.21 kB
createService 640 B
createSessionStorage 2.33 kB
createStorage 2.3 kB
createUrlSearchParamsInHashProvider 1.21 kB
createUrlSearchParamsInHashStorage 2.34 kB
createUrlSearchParamsProvider 1.21 kB
createUrlSearchParamsStorage 2.34 kB
defaultScheduler 1.5 kB
defineManifest 983 B
domUpdate 1.23 kB
emitExtendable 1.09 kB
fromMetaGlob 203 B
fromWebpackContext 131 B
getBreakpoints 776 B
getInstances 2.86 kB
inject 176 B
injectContext 675 B
injectContextSync 634 B
jsonSerializer 95 B
localStorageProvider 1.21 kB
memoryStorageProvider 1.21 kB
namespaceQualifier 120 B
nextFrame 115 B
perTarget 322 B
provide 182 B
provideContext 704 B
provideRootContext 748 B
read 126 B
sessionStorageProvider 1.21 kB
setBreakpoints 806 B
signal 925 B
subscribeContext 1.44 kB
swap 2.9 kB
toggle 177 B
until 172 B
urlSearchParamsInHashProvider 1.21 kB
urlSearchParamsProvider 1.21 kB
useBreakpoint 1.44 kB
useInView 1.43 kB
useKey 1.47 kB
useMediaQuery 1.06 kB
useMutation 1.4 kB
usePointer 1.85 kB
usePrefersReducedMotion 1.09 kB
useRaf 1.95 kB
useResize 1.43 kB
useScroll 2.67 kB
useScrollProgress 3.64 kB
useWindowScroll 2.66 kB
useWindowSize 1.43 kB
utils 9.25 kB
utils/DEFAULT_DAMP_FACTOR 109 B
utils/INERTIA_FRAME 97 B
utils/MAX_SPRING_RATIO 100 B
utils/SCROLL_ALIGNMENTS 117 B
utils/SCROLL_AXES 100 B
utils/TRANSFORM_PROPS 137 B
utils/TRANSITION_OPTIONS 132 B
utils/camelCase 449 B
utils/capitalize 119 B
utils/clamp 133 B
utils/clamp01 149 B
utils/clampDampFactor 157 B
utils/createEaseInOut 120 B
utils/createEaseOut 91 B
utils/createElement 638 B
utils/createRange 205 B
utils/damp 211 B
utils/debounce 121 B
utils/decayOver 162 B
utils/deepmerge 312 B
utils/easeInCirc 94 B
utils/easeInCubic 81 B
utils/easeInExpo 97 B
utils/easeInOutCirc 150 B
utils/easeInOutCubic 141 B
utils/easeInOutExpo 150 B
utils/easeInOutQuad 139 B
utils/easeInOutQuart 140 B
utils/easeInOutQuint 140 B
utils/easeInOutSine 156 B
utils/easeInQuad 80 B
utils/easeInQuart 81 B
utils/easeInQuint 81 B
utils/easeInSine 104 B
utils/easeLinear 77 B
utils/easeOutCirc 121 B
utils/easeOutCubic 111 B
utils/easeOutExpo 124 B
utils/easeOutQuad 110 B
utils/easeOutQuart 112 B
utils/easeOutQuint 111 B
utils/easeOutSine 132 B
utils/enterTransition 679 B
utils/fold 200 B
utils/getOffsetSizes 268 B
utils/historyPush 391 B
utils/historyReplace 392 B
utils/inertiaDecay 199 B
utils/inertiaFinalValue 187 B
utils/inertiaStep 232 B
utils/inertiaTimeConstant 178 B
utils/isBoolean 90 B
utils/isDefined 87 B
utils/isFunction 86 B
utils/isNull 78 B
utils/isNumber 103 B
utils/isObject 115 B
utils/isString 89 B
utils/kebabCase 421 B
utils/leaveTransition 679 B
utils/lerp 120 B
utils/loadImage 245 B
utils/loadLink 776 B
utils/loadScript 697 B
utils/lockScroll 565 B
utils/lowerCase 84 B
utils/map 128 B
utils/matrix 150 B
utils/mean 147 B
utils/memo 217 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 266 B
utils/pascalCase 434 B
utils/random 93 B
utils/randomInt 132 B
utils/randomItem 163 B
utils/round 130 B
utils/saveActiveElement 571 B
utils/scrollPosition 857 B
utils/scrollTo 1.86 kB
utils/selectorFor 2.78 kB
utils/setClassesOrStyles 218 B
utils/smoothTo 2.83 kB
utils/snakeCase 421 B
utils/spring 343 B
utils/throttle 151 B
utils/transform 286 B
utils/transition 577 B
utils/trapFocus 717 B
utils/untrapFocus 587 B
utils/upperCase 84 B
utils/wait 103 B
utils/withLeadingCharacters 142 B
utils/withLeadingSlash 152 B
utils/withTrailingCharacters 143 B
utils/withTrailingSlash 153 B
utils/withoutLeadingCharacters 127 B
utils/withoutLeadingCharactersRecursive 144 B
utils/withoutLeadingSlash 138 B
utils/withoutTrailingCharacters 129 B
utils/withoutTrailingCharactersRecursive 147 B
utils/withoutTrailingSlash 140 B
utils/wrap 154 B
viewTransition 1.66 kB
watchAttributeNamespace 2.55 kB
watchAttributes 2.02 kB
whenDOMSettled 2.35 kB
withDrag 4.03 kB
withInView 2.12 kB
withKey 2.15 kB
withMutation 2.09 kB
withPointer 2.52 kB
withRaf 2.63 kB
withScrollProgress 4.34 kB
write 124 B

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Code Review

Risk: Low — No demonstrable defects were found in the reviewed changes; the reviewed scope is safe to merge aside from the files not opened in this pass.

The MR ports the listed UI families to v4, adds the Shopify partial-rendering Fetch adapter, restores the generic transition mixin, and exposes component-scoped diagnostics. It also incorporates fixes for header normalization, asynchronous partial application failures, and failed video loading.

Notes:

  • Reviewed: packages/v4/migration/Fetch/Fetch.ts, packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, packages/v4/migration/FigureVideo/FigureVideoTwicpics.ts, packages/v4/src/Base.ts, packages/v4/src/diagnostics.ts, packages/v4/src/diagnostic-contract.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/Timer/Timer.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/Figure/AbstractFigure.ts, packages/v4/migration/Menu/Menu.ts, packages/v4/migration/ScrollTo/ScrollTo.ts, and packages/v4/migration/Sentinel/Sentinel.ts.
  • Not opened: every other file in the supplied skipped-files list, including the remaining migration source/spec files, package/export files, and documentation diffs.

Review usage: 126,466 in (93,796 cached) / 1,606 out tokens — $0.0299 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 816983b.

Previous review runs

Previous run archived 2026-08-24T09:52:00Z

Code Review

Risk: Medium — issues that should be addressed before merge.

This MR ports the listed UI families, adds the Shopify partial-rendering Fetch adapter, restores the generic transition mixin, and exposes component-scoped diagnostics. I reviewed packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, packages/v4/src/Base.ts, packages/v4/src/diagnostics.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/Menu/MenuList.ts, packages/v4/migration/Transition/withTransition.ts, and packages/v4/src/diagnostic-contract.ts; I did not open the other files listed in <skipped_files>.

1 issue found:

  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:217 — Read popstate headers across all supported representations

Still open from earlier reviews (2 findings):

  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:246issue: Normalize popstate header detection across HeadersInit forms
  • packages/v4/migration/FigureVideo/FigureVideoTwicpics.ts:118issue: Resolve TwicPics video loading failures

Review usage: 148,017 in (110,966 cached) / 1,974 out tokens — $0.0346 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 6273853.

Previous run archived 2026-08-24T09:44:32Z

Code Review

Risk: Medium — issues that should be addressed before merge.

This change ports the listed UI families, adds Shopify partial rendering support, restores the generic transition mixin, and exposes component-scoped diagnostics. I reviewed packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, packages/v4/migration/FigureVideo/FigureVideoTwicpics.ts, packages/v4/migration/Figure/AbstractFigure.ts, packages/v4/migration/Figure/FigureShopify.ts, packages/v4/migration/Menu/Menu.ts, packages/v4/migration/Timer/Timer.ts, packages/v4/migration/Toaster/Toast.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/src/Base.ts, and packages/v4/src/diagnostics.ts. I did not open the remaining files listed under skipped_files.

1 issue found:

  • issuepackages/v4/migration/FigureVideo/FigureVideoTwicpics.ts:118 — Resolve TwicPics video loading failures

Notes:

  • The intent describes ten component families plus FetchShopifyPartial; this review did not open the remaining family files in the supplied skipped-file list.

Still open from earlier reviews (1 finding):

  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:246issue: Normalize popstate header detection across HeadersInit forms

Review usage: 147,640 in (105,579 cached) / 1,679 out tokens — $0.0366 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 6d291e7.

Previous run archived 2026-08-24T09:39:32Z

Code Review

Risk: Medium — issues that should be addressed before merge.

This change ports ten UI families, adds FetchShopifyPartial, restores the generic transition mixin, and exposes component-scoped diagnostics. The previously reported header-normalization, partial-apply rejection, and failed-video-load issues are addressed in the reviewed code.

1 issue found:

  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:246 — Normalize popstate header detection across HeadersInit forms

Notes:

  • Reviewed: packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, packages/v4/src/Base.ts, packages/v4/src/diagnostics.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/migration/Menu/MenuList.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/AnchorNav/AnchorNav.ts, and packages/v4/migration/Figure/AbstractFigure.ts.
  • Not opened: all other files listed under <skipped_files>, including the remaining migration-family sources/specs, Fetch index/spec, transition index/specs, v4 exports/diagnostic files, package metadata, ESLint rule files, and the documentation/report diffs.

Review usage: 221,820 in (175,689 cached) / 2,324 out tokens — $0.0446 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit d364868.

Previous run archived 2026-08-24T09:38:29Z

Code Review

Risk: Medium — issues that should be addressed before merge.

This MR ports the documented component families, restores the generic transition mixin, adds Shopify partial rendering, and exposes component-scoped diagnostics. I reviewed these changed files: packages/v4/src/Base.ts, packages/v4/src/diagnostics.ts, packages/v4/src/diagnostic-contract.ts, packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/Menu/MenuList.ts, packages/v4/migration/Figure/AbstractFigure.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, packages/v4/migration/ScrollTo/ScrollTo.ts, packages/v4/migration/AnchorNav/AnchorNavLink.ts, and packages/v4/migration/Toaster/Toast.ts. I did not open the remaining files in .code-review-skipped/.

2 issues found:

  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:183 — Handle partial application failures explicitly
  • issuepackages/v4/migration/FigureVideo/FigureVideo.ts:78 — Resolve video loading when media errors occur

Notes:

  • The skipped-file list includes the remaining changed source, test, config, and documentation diffs; those files were not reviewed.

Still open from earlier reviews (3 findings):

  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:153issue: Normalize all supported Headers input forms
  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:186issue: Partial apply failures become unhandled rejections
  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:126issue: Headers objects bypass partial request validation

Review usage: 286,898 in (237,068 cached) / 3,272 out tokens — $0.0528 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 40270d4.

Previous run archived 2026-08-24T08:41:55Z

Code Review

Risk: Medium — issues that should be addressed before merge.

The reviewed source files add Sentinel, Sticky, Hoverable, ScrollTo, AnchorNav, Menu, Timer, Toaster, Figure, FigureVideo, SliderDots, generic transition support, and FetchShopifyPartial implementations. I reviewed: packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/migration/Timer/Timer.ts, packages/v4/migration/Toaster/Toast.ts, packages/v4/migration/Toaster/Toaster.ts, packages/v4/migration/Menu/Menu.ts, packages/v4/migration/Menu/MenuList.ts, packages/v4/migration/AnchorNav/AnchorNavLink.ts, packages/v4/migration/Slider/SliderDots.ts, packages/v4/migration/FigureVideo/FigureVideo.ts, and packages/v4/migration/Sentinel/Sentinel.ts. I did not open the remaining skipped files, including the other AnchorNav, Fetch, Figure, FigureVideo, Hoverable, Menu, ScrollTo, Sticky, Timer, Toaster, Transition, migration index, and report diffs.

2 issues found:

  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:186 — Partial apply failures become unhandled rejections
  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:126 — Headers objects bypass partial request validation

Still open from earlier reviews (1 finding):

  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:153issue: Normalize all supported Headers input forms

Review usage: 143,612 in (103,193 cached) / 2,068 out tokens — $0.0366 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit bea4464.

Previous run archived 2026-08-20T09:43:48Z

Code Review

Risk: Low — no blocking issues; safe to merge aside from nits.

This change ports the listed UI families, restores the generic transition mixin, and adds Shopify partial-rendering support to Fetch. I reviewed packages/v4/migration/Sticky/Sticky.ts, packages/v4/migration/Fetch/FetchShopifyPartial.ts, packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts, packages/v4/migration/Transition/withTransition.ts, packages/v4/migration/Sentinel/Sentinel.ts, packages/v4/migration/Timer/Timer.ts, and packages/v4/migration/Toaster/Toaster.ts. I did not open the remaining files in the supplied skipped-files list, including the other component sources, specs, indexes, REPORT.md, and transition files.

Still open from earlier reviews (1 finding):

  • packages/v4/migration/Fetch/FetchShopifyPartial.ts:153issue: Normalize all supported Headers input forms

Review usage: 94,959 in (62,104 cached) / 1,329 out tokens — $0.0276 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 22fd1d1.

Previous run archived 2026-08-20T08:47:05Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Adds the ten migration families described in the intent, restores the generic withTransition mixin, and implements the Shopify partial-rendering adapter. I reviewed these changed source diffs: Fetch/FetchShopifyPartial.ts, Sticky/Sticky.ts, Transition/withTransition.ts, Toaster/Toaster.ts, Toaster/Toast.ts, Menu/Menu.ts, Menu/MenuList.ts, Timer/Timer.ts, Sentinel/Sentinel.ts, ScrollTo/ScrollTo.ts, FigureVideo/FigureVideo.ts, AnchorNav/AnchorNavTarget.ts, and Figure/AbstractFigure.ts. I did not open the remaining skipped diffs: the AnchorNav specs and other AnchorNav sources, FetchShopifyPartial spec/index, Figure specs and remaining Figure sources/index/utils, FigureVideo specs/Twicpics/index, Hoverable sources/specs/index, Menu specs/MenuBtn/index, REPORT.md, ScrollTo spec/index, Sentinel spec/index, Sticky spec/index, Timer specs/TimerProgress/index, Toaster specs/index, Transition/Transition/ViewTransition/index, and migration/index.

1 issue found:

  • issuepackages/v4/migration/Fetch/FetchShopifyPartial.ts:153 — Normalize all supported Headers input forms

Review usage: 188,967 in (153,453 cached) / 2,222 out tokens — $0.0365 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit e13bd44.

Previous run archived 2026-08-20T08:03:20Z

Code Review

Risk: Low — no blocking issues; safe to merge aside from nits.

This MR ports the Sentinel, Sticky, Hoverable, ScrollTo, AnchorNav, Menu, Timer, Toast/Toaster, Figure, FigureVideo, and FetchShopifyPartial families to v4, together with migration documentation and corrected specs. I reviewed packages/v4/migration/Fetch/Fetch.ts, .code-review-skipped/packages__v4__migration__Fetch__FetchShopifyPartial.ts.diff, .code-review-skipped/packages__v4__migration__Toaster__Toaster.ts.diff, .code-review-skipped/packages__v4__migration__Toaster__Toast.ts.diff, .code-review-skipped/packages__v4__migration__Timer__Timer.ts.diff, .code-review-skipped/packages__v4__migration__Sticky__Sticky.ts.diff, .code-review-skipped/packages__v4__migration__ScrollTo__ScrollTo.ts.diff, .code-review-skipped/packages__v4__migration__Hoverable__Hoverable.ts.diff, .code-review-skipped/packages__v4__migration__FigureVideo__FigureVideo.ts.diff, and .code-review-skipped/packages__v4__migration__AnchorNav__AnchorNav.ts.diff. I did not open the remaining skipped diffs: the AnchorNav specs/target/link/index files, FetchShopifyPartial spec/index files, Figure source/spec/index/utils files, FigureVideo specs/Twicpics/index files, Menu source/spec/index files, ScrollTo spec/index files, Sentinel source/spec/index files, Sticky spec/index files, Timer source/progress/spec/index files, Toaster specs/index file, migration index, or REPORT.md.


Review usage: 120,355 in (88,142 cached) / 2,217 out tokens — $0.0312 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 99bbac0.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.04%. Comparing base (3ff073e) to head (816983b).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #861   +/-   ##
=======================================
  Coverage   97.04%   97.04%           
=======================================
  Files         175      175           
  Lines        4535     4535           
  Branches     1323     1322    -1     
=======================================
  Hits         4401     4401           
  Misses        122      122           
  Partials       12       12           
Flag Coverage Δ
eslint-plugin-js-toolkit 94.43% <ø> (ø)
js-toolkit 97.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

v4 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

No benchmark moved beyond the noise floor.

Within noise (18)
Group Benchmark Base Head us / component Change
destroy 1000 flat components, one removal flat 2.80 ms 2.80 ms 2.80 -0.0%
destroy 5000 flat components, one removal flat 16.6 ms 16.6 ms 3.32 0.0%
mount 1000 components, one insertion control — declared but unregistered 2.00 ms 2.10 ms 2.10 +5.0%
mount 1000 components, one insertion flat 16.8 ms 16.7 ms 16.70 -0.6%
mount 1000 components, one insertion in-view — one controller per element 35.4 ms 30.3 ms 30.30 -14.4%
mount 1000 components, one insertion nested 4 deep 14.8 ms 14.2 ms 14.20 -4.1%
mount 1000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 73.7 ms 73.9 ms 73.90 +0.3%
mount 1000 components, one insertion responsive option — breakpoint cascade per mount 17.5 ms 17.3 ms 17.30 -1.1%
mount 1000 flat components, 1 vs 10 insertions 1 insertion 11.6 ms 11.3 ms 11.30 -2.6%
mount 1000 flat components, 1 vs 10 insertions 10 insertions 12.5 ms 12.8 ms 12.80 +2.4%
mount 5000 components, one insertion control — declared but unregistered 17.9 ms 18.4 ms 3.68 +2.8%
mount 5000 components, one insertion flat 65.0 ms 66.0 ms 13.20 +1.5%
mount 5000 components, one insertion in-view — one controller per element 157.0 ms 167.7 ms 33.54 +6.8%
mount 5000 components, one insertion nested 4 deep 69.1 ms 66.7 ms 13.34 -3.5%
mount 5000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 339.9 ms 343.3 ms 68.66 +1.0%
mount 5000 components, one insertion responsive option — breakpoint cascade per mount 85.8 ms 85.8 ms 17.16 0.0%
mount 5000 flat components, 1 vs 10 insertions 1 insertion 67.3 ms 65.5 ms 13.10 -2.7%
mount 5000 flat components, 1 vs 10 insertions 10 insertions 68.4 ms 67.1 ms 13.42 -1.9%

Restores v3's structure, where the behaviour lived in a mixin and the
Transition component was withTransition(Base). The earlier port
collapsed the two on the finding that the decorator used `this` for
nothing but reading two options — true at one consumer, false at five:
MenuList, AbstractFigure, FigureVideo and AnchorNavLink each
reimplemented the same state/target/enter/leave/toggle block.

This is v4's first non-service mixin. It reuses createServiceMixin's
type shape (MixedClass) so a consumer threads its own props exactly as
it does for withResize, and its concrete-parameter-plus-cast split for
the same reason core needs it: a class extending a type parameter must
otherwise declare constructor(...args: any[]).

It declares no config. BaseConfig requires a name for the static side
to stay assignable to Base's, and that name would be inherited by any
consumer which forgot its own, registering a component under a name it
never chose — so consumers keep spreading TRANSITION_OPTIONS, the one
line they already had. Core's service mixins declare no config either.

transitionOptions is a new hook, defaulting to $options: it is what
lets MenuList force enterKeep/leaveKeep without reimplementing
enter()/leave(). v3 forced them by overriding the $options getter,
which v4 refuses — $options is a read-only view with no override
point — so the override moves onto the declaration the mixin reads.

ViewTransition gains the same type parameter but stays a direct
Transitionable implementation: the browser owns its animation and its
event names are its own.
MenuList, AbstractFigure, FigureVideo and AnchorNavLink each carried
their own state/target/enter/leave/toggle block around the same two
core utility calls. -138 net lines, and every spec passes unchanged.

AnchorNavLink becomes withTransition(ScrollTo), which is v3's own
declaration restored, and is the case that shows why the mixin has to
exist: the transition belongs on a class that already extends
something else. Its spec asserting the inherited ScrollTo onClick
still fires confirms magic-name handler binding traverses the mixin
now in the prototype chain.

MenuList's forced enterKeep/leaveKeep is now a four-line
transitionOptions getter instead of two reimplemented methods.

AbstractFigure and FigureVideo keep only their target override, onto
the img and video refs — which is what that hook is for.
…amilies

I never ran `npm run lint` while porting: main was clean and this branch
had introduced six errors plus two warnings. All fixed, and oxfmt (the
project's formatter — not prettier) applied to the whole migration dir.

The substantive one is the project's own `js-toolkit(no-write-in-read-
phase)` rule firing twice on Sticky, which is gap 43 caught by the lint
rule that gap asked for. Sticky.hide()/show() write classList and
restack every instance, and their caller is scrolled() — the scroll
service emits from inside defaultScheduler.read(), so those writes
interleave into the read phase. The DOM half moves to an
applyVisibility() marked @Write; isVisible stays synchronous, since it
is logical state setPosition() reads on every instance and only the DOM
work belongs in a later phase. One spec assertion now awaits settle().

The rest are mechanical: two async resized() methods handed a promise
to a hook declared void (no-misused-promises), and three returns of
`any` through a loose BaseConstructor host or a non-literal dynamic
import (no-unsafe-return).
Gap 45 is resolved rather than open: withTransition restores v3's
mixin, both transition classes are generic, and the four consumers lost
138 net lines. Records why §4b's collapse was right about its evidence
and wrong about its scope — a consumer needs the state machine and
events around the two utility calls, not the calls — plus why the mixin
can declare no config and why MenuList's forced flags land on
transitionOptions. §4b and the size-table note now point forward to it
instead of leaving the superseded conclusion as the last word.

Gap 43 gains the case its own "ask" produced: the lint rule it asked
for shipped, and immediately found a third instance in Sticky's
scrolled() — a hook the earlier thirteen-family audit had not checked,
because the first two cases were in ticked(). Also records that the fix
has to split the method rather than decorate it, since @Write would
defer the state flag along with the paint.

§16's closing claim is corrected: the round was chosen as the one
needing no design deliberation and produced two findings anyway,
neither of which was visible from the component list.
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts
… gap 2

Two corrections, both from review.

**The mixin can declare config, and I was wrong that it could not.** The
first version had all five consumers spread TRANSITION_OPTIONS, on the
belief that BaseConfig's required `name` blocked a mixin from declaring
one. resolveConfig() folds each own config with `{ ...merged, ...own }`,
so a config with no `name` key contributes its options and leaves the
name below it untouched — which is what a mixin is. The declaration is
typed rather than named, the five consumers dropped their spread, and
two new specs pin it: the options resolve on a consumer declaring none,
and resolveConfig().name still answers the consumer's own.

SliderDots deliberately keeps its own spread and its direct utility
calls: it transitions its child dots, two elements per update, with no
state, no single target and no events, so the mixin's shape does not
fit. §4b's observation was right for the component it was made on.

**Gap 2's audit undercounted, and this is the more interesting one.**
It measured $options *assignments* via grep '\$options\.[a-z]* *=',
which by construction cannot match a getter override — the other way v3
allowed an option to be reshaped. `grep -rl 'get [$]options'` over ui
finds two, MenuList and FrameLoader, and both force enterKeep/leaveKeep
so a transition keeps its end state. Being overridable is *why*
$options was a getter in v3, and that is the whole of what ui used it
for. The ruling stands — both of its coherence arguments apply to an
override as much as to an assignment — but it costs one capability more
than recorded, and transitionOptions is the narrow replacement rather
than an incidental convenience. Recorded in gap 2 and gap 45, including
that a lint rule for this half must match `get $options`.
…ke class

**The mixin dropped both halves of v3's target surface.** v3's `target`
getter returns HTMLElement | HTMLElement[], and enter/leave/toggle each
take an optional target which *replaces* it for that one call. Restored:
#elements() flattens `[target ?? this.target]`, and #run() fans one
direction across them with Promise.all over a synchronous map — which is
the ordering v3 got from handing the whole list to one transition()
call, since every element is staged before any reaches the next frame.

**SliderDots is the consumer that proves it, and I had it wrong twice.**
§4b concluded the decorator used `this` only to read two options; I then
wrote that SliderDots could not use the mixin because it transitions two
elements in two directions. v3's source contradicts both: it *is*
withTransition(AbstractSliderChild), overrides get target() to return
its whole dots ref list, and calls leave(previous)/enter(next). It is
the canonical consumer of both halves — which is exactly why dropping
them made it look like a non-consumer. Now a mixin consumer again, with
its direct utility imports gone and the currentIndex -1 guard kept (v3
throws there).

**And a flake class worth naming (gap 46).** A spec asserting a kept
transition class after settle() is racy: open()/close() start the
transition without returning it, and the end state lands several frames
later. MenuList failed ~1 run in 3 under full-suite load while passing
6/6 in isolation, which is what made it look like noise the first two
times I saw it. Polled with waitForClass in the three positive
assertions of this shape; the negative one is deliberately left alone,
since leaveTransition() clears the other direction synchronously, so
polling for an absence would pass before anything happened. Five
consecutive full-suite runs clean.
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts
…s $warn half

AbstractFigure was reimplementing a `warn` utility. So were ten other
files — one four-line `console.warn` wrapper per family, none aware of
the others — while gap 31's cancelable diagnostic channel sat unused,
because it was closed to consumers: `warn`/`reportDiagnostic` were
`@internal` and unexported, and ToolkitDiagnosticCode was a closed
union of core's own codes. A consumer could listen and could not report.

Core changes:
- ToolkitDiagnosticCode is now core's enumerated set (kept as
  ToolkitCoreDiagnosticCode, still pinned by its spec) OR a
  `${string}.${string}` consumer code. The namespace is mandatory
  because a listener filters on it.
- Base gains $warn(code, message) and $error(code, message, error),
  filling in the component name and element — the two things a listener
  filters and inspects on, and the instance is what always knows both.
- warn/reportDiagnostic become public for the one call site with no
  instance to report as (parseEventDefinition, a module-level parser).

The severity split is the part worth keeping: $warn for a malformed
declaration where nothing threw, $error for a recovered failure that
has a cause and must carry it. The rule is whether v3 had an error in
hand — its `warn('Invalid JSON in …:', error)` becomes $error, its
`warn('…')` becomes $warn. The image and poster load failures now carry
the Error v3 discarded.

Eleven local copies deleted, Track/utils.ts with the last of them.
Three specs that asserted on console.warn now assert on the channel,
which pins the code rather than the sink. One of them turned out to be
leaking a document listener that cancelled the default sink for every
later test in the file — fixed.

Also updated the project's own no-deprecated-properties rule, which
said `"$warn" is removed in v4. Use console.warn() instead.` — true
when written, now the opposite of the advice.
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts Outdated
Comment thread packages/v4/migration/FigureVideo/FigureVideo.ts
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

v3 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

No benchmark moved beyond the noise floor.

Within noise (10)
Group Benchmark Base Head us / component Change
destroy 1000 flat components — v3 v3 — destroy 16.3 ms 16.0 ms 16.00 -1.8%
destroy 1000 flat components — v4 v4 — destroy 3.70 ms 2.70 ms 2.70 -27.0%
swap 1000 components — v3 v3 — control 0.20 ms 0.20 ms 0.20 0.0%
swap 1000 components — v3 v3 — flat 40.0 ms 43.1 ms 43.10 +7.8%
swap 1000 components — v3 v3 — nested 45.6 ms 46.7 ms 46.70 +2.4%
swap 1000 components — v3 v3 — realistic 124.2 ms 125.2 ms 125.20 +0.8%
swap 1000 components — v4 v4 — control 2.20 ms 2.00 ms 2.00 -9.1%
swap 1000 components — v4 v4 — flat 17.2 ms 16.5 ms 16.50 -4.1%
swap 1000 components — v4 v4 — nested 15.2 ms 16.3 ms 16.30 +7.2%
swap 1000 components — v4 v4 — realistic 71.3 ms 74.5 ms 74.50 +4.5%

All three were real, all three high-confidence, and none was caught by
the specs I wrote — worth recording as a class: each is an unhappy path
the port inherited from v3 unexamined.

**Headers were only read in their record form.** RequestInit.headers is
a HeadersInit — a record, a list of tuples, or a Headers instance — and
spreading one only sees the record; a Headers yields no own enumerable
keys at all. So a custom header written that way passed
canUsePartials() unnoticed and was then dropped, because the partials
API forwards nothing but { url, signal }. Now read through a
headerNames() helper covering all three forms; the method/body check no
longer needs the merged object either.

**A rejected partials.apply() was an unhandled rejection.** The apply
phase is fire-and-forget on purpose, so an apply failure is not
misattributed to the fetch phase — but without a catch it had no
observable failure at all. Routed through the component's own error().

**FigureVideo hung forever when its sources failed.** loadSources()
waited on `loadeddata` alone (as v3 does), so a video whose sources all
fail never settled, and mounted() awaits it — meaning no enter
transition, no `load` event, no hasLoaded, and no error either. It now
settles on `error` too and mounted() reports it, leaving the component
un-loaded so a later mount cycle retries.

Five specs added, one per behaviour plus the internal-header case that
must still take the partials path.
Three high-confidence issues came out of the PR review rather than the
suite, and they share a shape worth naming: each is an error path v3
also got wrong or never had, carried over because the port asked
whether behaviour matched v3 rather than what happens on failure.

Every spec in the port was written from the component's documented
behaviour, so each exercises the path the component is for. None of the
three bugs is reachable that way — two need a resource to fail, one
needs a caller to use a different-but-legal form of a platform type.
The rule that falls out: for each awaited external outcome, ask what
settles it on failure; for each platform type accepted at a boundary,
ask which of its forms the code actually reads.
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts
Comment thread packages/v4/migration/FigureVideo/FigureVideoTwicpics.ts
The second hard-coded count, in the consumer fixture `check:package`
runs — whose own comment warns that `npm test` does not cover it, which
is exactly how I missed it after updating the one in exports.spec.ts.
Verified against the built dist: 86 root exports, with `warn` and
`reportDiagnostic` both surviving the pack. Both are now asserted by
name, so the next person gets a useful failure rather than an
off-by-two on a bare number.
Comment thread packages/v4/migration/Fetch/FetchShopifyPartial.ts
The review's second round found my first round was incomplete, and
chasing it turned up a worse bug underneath.

**The popstate history guard still indexed headers as a record**, in
both FetchShopifyPartial.applyPartials() and the base Fetch.update() —
I had fixed canUsePartials() and stopped there. A `Headers` instance
carrying the internal x-triggered-by header read as undefined, so a
back/forward navigation pushed a new history entry.

**And the root cause was mergeRequestInit(), which spread headers.**
Spreading a `Headers` yields nothing, so a caller's
`fetch(url, { headers: new Headers(…) })` was silently emptied before
the request was built — the header never reached fetch() at all. That
is the base Fetch on its ordinary path, not just the partials variant.
Fixed by merging through one headerEntries() reader, which headerNames()
and headerValue() are now derived from, all three living in Fetch.ts
beside HEADER_NAMES.

**FigureVideoTwicpics.loadSources() still hung**, because it overrides
the base entirely and I had only fixed the base — it waits on
`canplaythrough` where the base waits on `loadeddata`, with the same
missing `error` listener. Same fix applied to the override.

Five specs added: a Headers instance and a tuple array both reach
fetch() with the per-call value winning, the popstate guard reads a
Headers instance, a non-popstate request still pushes, and the
TwicPics override settles and reports on a media error.
@titouanmathis
titouanmathis merged commit fbb572d into main Aug 24, 2026
12 checks passed
@titouanmathis
titouanmathis deleted the feat/v4-migration-simple-families branch August 24, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant