Faithful port of Base UI (@base-ui/react, base-ui.com) to the octane
renderer, mirroring the @octanejs/radix methodology. Ported from the pinned
mui/base-ui checkout at v1.6.0 (the version installable from this
environment's npm), proven by differential parity against the real
@base-ui/react. Standing discipline (from the Radix port): when a faithful
port can't reproduce React behavior, fix octane with a regression test + changeset; never
work around it in the binding.
Phase 3g — Toast (2026-07-28). Green: 155 base-ui tests (97 differential + 58 behavior/namespace), typecheck +
format:checkclean. Subpath coverage 31/43 → 32/43.
packages/base-ui/src/toast.ts(~1,900 loc) ports the store,createToastManager,useToastManagerand all 11 parts. Toast is the one overlay family with no trigger and no floating store: toasts are created imperatively — fromuseToastManager().add()inside a component or acreateToastManager()handle outside one entirely — and theToastStoreowns the list, the per-toast auto-dismiss timers, and the pause/resume rules (hover, focus, window blur, touch). Supporting additions:utils/generateId(toast ids are minted outside render, so they cannot come from a hook),utils/useOnMount, and the swipe-ignore attribute constants.PLAN CORRECTION. This phase was scoped at ~4,200 loc because "
useSwipeDismissis shared with Drawer, so it lands here". That is wrong for 1.6.0: Toast implements its swipe inline inToastRootand imports only two pure geometry helpers (getDisplacement,getElementTransform) from that file, which are inlined here. The 1,208-line hook has exactly two consumers, both Drawer (DrawerViewport,DrawerSwipeArea), so it moves to Phase 6 where it is actually used. Real size of this phase: ~2,850 loc of upstream source.Parity bug caught by the rig:
Toast.Title/Toast.Descriptionwere written withuseBaseUiId"to match the rest of the binding", which prefixes ids withbase-ui-. Upstream reaches for the RAW@base-ui/utils/useIdthere, with no prefix — so everyaria-labelledby/aria-describedbydiverged. Now uses octane's rawuseId. This is the third time the prefixed-vs-raw id distinction has bitten; the rule is thatuseBaseUiIdis only correct where upstream itself callsinternals/useBaseUiId.Differential covers the empty viewport and the add path (one toast, two toasts,
limit, high priority), driven by clicking a real "add" button on both runtimes. Behavior tests cover what the rig cannot see: the imperative manager (add/update/close, id-reuse upsert),onClose/onRemoveordering, auto-dismiss, hover pausing and resuming the timer,type: 'loading'opting out of the timer plus thepromisehelper's loading→success handoff,limitflagging rather than removing, and the close button.Swipe IS testable in jsdom — correcting an earlier claim. After Menu I wrote that gesture paths were jsdom-blind across the board. That is true only of the hover/
safePolygonfamily, which needs realgetBoundingClientRectgeometry. Swipe is pureclientX/clientYdelta math, andgetElementTransformreadingtransform: nonejust yields a{0,0,1}baseline, so synthetic pointer events drive it end to end. Three swipe tests now cover dismissal past the threshold, direction locking + the movement CSS variable + spring-back below the threshold, and the guard that a drag starting on an interactive element does not swipe. Two gotchas for whoever writes the next one:movementX/movementYare getter-only on jsdom's MouseEvent so they must bedefineProperty'd, and the FIRSTpointermoveis consumed as the iOS baseline reset, so a drag needs two moves.Still not covered: the close→remove gap — jsdom runs no animations, so
useOpenChangeCompleteresolves in the same flush and only the ORDER of the two callbacks is deterministic.
Phase 3f COMPLETE — stage 4, Menubar + ContextMenu (2026-07-27). Green: 142 base-ui tests (93 differential + 49 behavior/namespace), typecheck +
format:checkclean. Subpath coverage 29/43 → 31/43.
src/menubar.ts(aCompositeRootofMenu.Triggers over oneFloatingTree, plus theMenubarContentrelay that liftshasSubmenuOpenonto the bar) andsrc/context-menu.ts(ContextMenuRoot+ContextMenuTrigger, with every other part re-exported fromMenuexactly as upstream'sindex.parts.tsdoes).This retires the last of stage 1's inert code. The
menubarandcontext-menuarms ofMenuParentwere transcribed verbatim in stage 1 with nothing to provide their contexts; the two context-only modules (utils/MenubarContext,utils/ContextMenuRootContext) now have owners, and every branch that reads them runs: menubar triggers render asCompositeItems withrole="menuitem", the backdrop cutout becomes the BAR rather than the trigger, the positioner takes its side from the bar's orientation, and a context menu gets a modal focus manager, fixed positioning and the itemmouseupguards that stop the opening right-click from activating an item under the cursor.MenuRootContextis now exported soContextMenu.Rootcan provideundefinedaround itsMenu.Root— that is what distinguishes "this Menu IS the context menu" from "this Menu is nested inside a ContextMenu.Trigger".Three test expectations were probed rather than assumed, after each failed first:
- A context menu's rendered
data-alignisend, not thestartthe branch requests — collision avoidance settles it, and jsdom reports a zero-size viewport. What IS deterministic and asserted:position: fixed, and--transform-origintracking the cursor exactly (y=80 →1px 74px, y=300 →1px 294px).transformis clamped to the same value for every point, so it proves nothing here.- Switching menus by pressing a sibling trigger does not hand over — it dismisses the open menu without opening the sibling. Once a menubar has an open menu the siblings become HOVER-driven (
openOnHoverfollows the bar'shasSubmenuOpen), which needssafePolygon+ rest timers and real pointer geometry. Left uncovered and documented in the test file, the same limitation as submenu open-on-hover in stage 3.- What a synthetic press DOES cover: opening from a trigger, the bar's
data-has-submenu-opentracking, and the owning trigger toggling its own menu shut.Phase 3f is now complete:
Menuat 20/20 parts,Menubar, andContextMenuat 19/19.
Phase 3f STAGE 3 — submenus (2026-07-27). Green: 129 base-ui tests (88 differential + 41 behavior/namespace), typecheck +
format:checkclean.Menunow covers all 20 upstream parts; themenusubpath is complete.Adds
MenuSubmenuRoot(providesMenuSubmenuRootContext, which is what makes the nestedMenuRootresolveparentto{ type: 'menu' }, then renders a plainMenuRoot) andMenuSubmenuTrigger. The trigger is the first user of thesubmenu-triggerarm ofuseMenuItem'sitemMetadata, and opening a submenu activates the sibling-close / parent-close / item-hover relaysMenuPositionerhas carried inertly since stage 1. It holds TWO stores at once: it is an item of the parent menu (parentCompositeList,itemProps,isActive) and the trigger of its own.A second rig limitation, and why it was NOT normalised away. With a submenu open, its trigger is the only tabbable child of the parent popup (
tabIndex: open || highlighted ? 0 : -1), so the parent focus manager focuses it and the trigger'sonFocussets the PARENT store'sactiveIndex→data-highlighted. Both runtimes share onedocument.activeElement, so only one can win — and unlike the focus-guardtabindex, this is derived STORE state, which no DOM-level normaliser can reconstruct. Confirmed it is the rig and not the port by mounting octane ALONE: the trigger is highlighted and isdocument.activeElement, exactly matching React. Normalisingdata-highlightedaway would blind the roving-focus comparison stage 2 deliberately preserved, so instead the open-submenu case byte-compares the SUBMENU'S OWN PORTAL SUBTREE (stepComparingSubtree), where the whole stage-3 payload lives — nestedinline-end/startplacement,data-nested, the popup's aria wiring back to the trigger, the nested items — and the trigger's highlighted state is asserted octane-only.Escape semantics were probed rather than assumed: dispatched from INSIDE the tree it closes only the child (
closeParentOnEscdefaults to false); dispatched ondocumentit closes both, because that target is outside both floating elements and each menu dismisses independently. The single-level tests can usedocumentprecisely because there is only one menu.Not ported here:
MenubarandContextMenu(stage 4), which fill in themenubar/context-menuparent branches transcribed in stage 1.
**Phase 3f STAGE 2 — the Menu item family (2026-07-27). Green: 121 base-ui tests (86 differential
- 35 behavior/namespace), typecheck +
format:checkclean.** Subpath coverage unchanged at 29/43 (Menu was already counted); themenusubpath goes from 5 parts to 18 of upstream's 20.Adds ~1,700 loc to
packages/base-ui/src/menu.ts:utils/stateAttributesMapping(itemMapping, deferred from stage 1 because it needs the checkbox data-attributes),item/(useMenuItemCommonProps+useMenuItem+MenuItem),link-item/,checkbox-item/+checkbox-item-indicator/,radio-group/+radio-item/+radio-item-indicator/,group/+group-label/,arrow/,backdrop/andviewport/.Menu.Separatorre-exports the sharedSeparatorexactly as upstream'sindex.parts.tsdoes.This is where Phase 3e is finally exercised end-to-end. Items register with the positioner's
CompositeList, souseListNavigationcan rovedata-highlightedand the rovingtabindexbetween them anduseTypeaheadcan match their collected labels. Behavior tests pin arrow-key roving, Home/End, single-character typeahead, buffer ACCUMULATION (typingbthenasearches"ba"and stays on Banana, where a non-accumulating implementation would jump to Apple), and theTYPEAHEAD_RESET_MSreset. 3e still needed no repairs.The differential helper from stage 1 got stronger rather than staying a strip: it now UNDOES
disableFocusInsideby restoring each element's stasheddata-tabindex, which is exactly whatenableFocusInsidedoes when focus returns. That keepstabindexVALUES in the byte-compare — which matters now that items are present, since their tabindex is roving state (open && highlighted ? 0 : -1) rather than a constant, and blanket-stripping it would have hidden a real roving-focus divergence.Two upstream behaviors worth recording, both confirmed by probe rather than assumption: opening a menu highlights the first item immediately (there is no "nothing highlighted" state after open), and typeahead buffers across keystrokes within
TYPEAHEAD_RESET_MSrather than matching each key independently.
React.memoonMenuRadioGroupis dropped (octane memoizes renders itself; the wrapper carries no behavioral contract), as areuseControlled'sname/statelabels, which exist only for React dev warnings.Upstream quirk transcribed, not repaired: Base UI's MENU indicators gate their render on
item.checkedrather than themountedflaguseTransitionStatusreturns — unlikeCheckbox.Indicator/Radio.Indicator, which usemounted. Unchecking therefore drops the node on the same commit and the exit transition never runs, which makes the accompanyinguseOpenChangeComplete→setMounted(false)pair dead for that direction.MenuCheckboxItemIndicator.tsxL26 does not even destructuremounted. This was raised as a bug in review; repairing it would diverge from the real@base-ui/react, so it is transcribed as-is, documented at both call sites, and pinned by differential TOGGLE steps that drive check → uncheck → re-check on both runtimes.Not in this stage:
SubmenuRoot/SubmenuTrigger(stage 3),MenubarandContextMenu(stage 4).
Phase 3f STAGE 1 — Menu open/close + roving-focus path (2026-07-27). Green: 108 base-ui tests (81 differential + 27 behavior/namespace), typecheck +
format:checkclean. Subpath coverage moves 28/43 → 29/43.
packages/base-ui/src/menu.ts(~1,750 loc) portsstore/MenuStore+store/MenuHandle,root/MenuRoot+MenuRootContext,trigger/,portal/,positioner/andpopup/, plusutils/findRootOwnerIdand theMenuOpenEventDetailstype. This is the first CONSUMER of the Phase 3e list-navigation infrastructure, which merged (#308) deliberately untested because a differential test needs a rendered component:MenuRootwiresuseListNavigation+useTypeaheadto the popup store, andMenu.Positionerwraps the popup in theCompositeListthat feeds their element/label refs. 3e needed no repairs — it works as ported.Supporting additions:
useOpenInteractionType(the statefulopenMethod+ trigger-props pair; onlyuseOpenMethodTriggerPropshad been ported, because Popover keepsopenMethodin its store),DROPDOWN_COLLISION_AVOIDANCE/TYPEAHEAD_RESET_MS/PATIENT_CLICK_THRESHOLDinutils/constants(Popover's local copy of the last one now imports it), and thecancel-open/sibling-openreasons.
MenubarContextandContextMenuRootContextland as CONTEXT-ONLY modules ahead of their components (stage 4).Menu'sparentunion branches on them in ~a dozen places; having the contexts now let every branch be transcribed verbatim instead of stubbed and re-threaded later. Nothing provides them yet, so those branches are inert.Rig limitation found and documented: a
Menu.Popuptakes focus on open, and the differential rig mounts both runtimes into ONE jsdom document. Whichever popup is focused last makes the other runtime's portal see an outwardfocusout, andFloatingPortal's non-modal tab management answers withdisableFocusInside(portalNode)— stampingtabindex="-1" data-tabindex="0"on that portal's focus guards. Both runtimes run identical code; the asymmetry is onedocument.activeElementfor two apps.parity.test.tsnormalises the guards' tabindex away for the open-menu fixtures and byte-compares everything else. (Popover sidesteps the same issue by running a modal focus manager, which turns portal tab management off.)Not in this stage:
Menu.Itemand the rest of the item family, submenus,Menubar,ContextMenu,Menu.Viewport/Arrow/Backdrop/Separator, andmenu/utils/itemMapping(which depends onMenuCheckboxItemDataAttributesand lands withCheckboxItemin stage 2). Item-level roving focus and typeahead matching are therefore untested until stage 2 — stage 1's behavior tests cover the layer below: arrow-key open from the trigger, Escape dismiss, click toggle, the ARIA contract,aria-orientationreaching the popup fromuseListNavigation, and focus entering the popup and returning to the trigger.
Phases 3a–3d COMPLETE (2026-07-26). Green: 89 base-ui tests (76 differential + 13 behavior), full monorepo suite green, typecheck + format clean. Subpath coverage moved from 22/43 to 28/43.
- Phase 3a — zero-dependency backlog.
Button(useButton+useRenderElement, includingfocusableWhenDisabledandnativeButton={false}),DirectionProvider(over the existing internalDirectionContext; octane stores theTextDirectionstring directly rather than Base UI's{ direction }wrapper, so nouseMemois needed),CSPProvider+ the newutils/CSPContext, andunstable-use-media-queryon octane's nativeuseSyncExternalStoreinstead of theuse-sync-external-storeshim. 11 differential fixtures.- Phase 3b — hover + focus interaction layer (~2,400 lines). Replaced BOTH
openOnHoverstubs with the real system:utils/floating/useHoverShared(delay resolution + open-event classification),useHoverInteractionSharedState(the per-popup mutable record: pointer type, timers, and the pointer-events mutation that keeps the cursor's path to the popup clickable),safePolygon(verbatim geometry),useHoverReferenceInteraction(trigger side),useHoverFloatingInteraction(popup side),useFocus(open-while-focused + blocked-focus bookkeeping), andFloatingDelayGroup/useDelayGroup(shared-delay tooltip groups). Addedplatform.os.macand theisInteractiveElement/isTargetInsideEnabledTriggerelement helpers. Deliberate omission: upstream's standalonefloating-ui-reactuseHovercombiner — no Base UI component uses it, and this binding does not republish that surface.- Faithfulness bug found and fixed by the hover tests.
PopoverRootrenderedPopoverInteractionsas a WRAPPER around the children (an earlier workaround for the octane Provider children dialect-flip bug), so the wrapper's component type changed on every open and tore down the whole subtree — including the trigger, whose element listeners and store registration were then pointing at a detached node, which is exactly why hover-close never fired. Base UI renders it as a headless SIBLING, and all four overlay Roots (Dialog, Popover, Tooltip, PreviewCard) now do the same. The trigger also regained upstream's keyed-fragment wrapper, plus keys on the focus guards (octane reconciles a returned array as a list, so unkeyed siblings would shift the keyed trigger anyway).- The underlying octane bug is fixed separately, so the workaround is gone entirely. A Provider's
childrenmay arrive as a compiled.tsrxchildren-block FUNCTION or as an element DESCRIPTOR, and both claimedscope.slots[0]— a compiled body stores its binding bag there, the descriptor path stores achildSlotrecord — so alternating between them had the incoming dialect adopt the outgoing one's record (TypeError: Cannot read properties of undefined (reading 'items'), subtree detached). Fixed in octane by #294. The sibling shape this binding now uses is dialect-stable (its Provider children are always an array), so these components do not depend on that fix — it simply means no binding has to keep dodging the bug.- Phase 3c — popup viewport (~700 lines).
utils/usePopupViewport(keeps a DOM clone of the outgoing content mounted beside the incoming content so a trigger switch can animate),utils/usePopupAutoResize(measure atmax-content→ pin previous size → animate to new),utils/getCssDimensions,utils/usePreviousValue,utils/FloatingPortalLite. Then the two missing parts on shipped components:Dialog.ViewportandPopover.Viewport. Parity note: upstream's baredata-currentJSX attribute serializes asdata-current="true", so the port passestruerather than''.- Phase 3d — Tooltip + PreviewCard (~3,000 lines).
src/tooltip.ts(Provider/Root/Trigger/ Portal/Positioner/Popup/Arrow/Viewport + store + handle), including the trigger's nested-trigger hover arbitration andtrackCursorAxisvia the newly portedutils/floating/useClientPoint.src/preview-card.ts(Root/Trigger/Portal/Positioner/Popup/ Backdrop/Arrow/Viewport + store + handle), whose distinctive piece is the inline-rect anchoring that pins the card to the hovered line of a wrapping<a>(utils/popups/inlineRect).- Rig note: hover open/close, focus open and delay grouping are timing- and pointer-driven, so none of them appear in a single innerHTML snapshot. They are covered by dedicated behavior tests (
popover-hover.test.ts,tooltip.test.ts,preview-card.test.ts) alongside the differential fixtures for structure.
SSR foundation (2026-07-25).
useIsHydratingnow uses Octane's actual server and client external-store snapshots and threads manual hook slots into both slider call sites. A dedicated Node-mode project verifies the hydration snapshot, accessible separators, hidden edge-aligned slider parts, and closed dialog rendering without a DOM. Client hydration adopts real Octane Base UI separator markup, transitions to the client snapshot, and remains interactive. Open-overlay and remaining component hydration are still follow-up work.
Phase 3 — ALERTDIALOG COMPLETE (2026-07). Green: 58 base-ui tests (54 differential + 4 behavior), full suite green.
src/alert-dialog.ts— a thin Dialog variant (the whole Dialog foundation already supported it):AlertDialogRoot=useRenderDialogRoot(props, 'alert-dialog')(forcesmodal: true,disablePointerDismissal: true,role: 'alertdialog'), and Trigger/Portal/Backdrop/ Popup/Title/Description/Close are Dialog's parts reused verbatim via theDialognamespace.AlertDialogHandle extends DialogHandle(enforces the alert-dialog invariants on its store). ExporteduseRenderDialogRootfromdialog.ts. The open modal alert dialog is byte-identical to real Base UI (onlyrole="alertdialog"differs from Dialog) — differential + 2 behavior tests (trigger→open→Close, Escape-still-dismisses-though-outside-press-is-disabled).
Phase 3 — POPOVER COMPLETE: OPEN (anchored-positioner) path landed + differential-verified (2026-07). Green: 55 base-ui tests (53 differential + 2 Popover behavior), full monorepo suite 2289 green. Ported the anchored-positioning layer, reusing
@octanejs/floating-ui's positioning engine maximally:utils/floating/useFloating.ts(Base UI's Store-baseduseFloating, octane-adapted — swaps@floating-ui/react-dom'suseFloatingfor@octanejs/floating-ui'susePositionFloating, keeping ALL theFloatingRootStorelogic) +utils/floating/useFloatingRootContext.ts(internal fallback store) +utils/useAnchorPositioning.ts(~430; the offset/flip/shift/limitShift/size/arrow middleware config, reusing@octanejs/floating-ui's re-exported@floating-ui/dommiddleware + the ref-awarearrow;@floating-ui/utilshelpers) +utils/hideMiddleware.ts+utils/adaptiveOriginMiddleware.ts+utils/usePositioner.ts+utils/useAnchoredPopupScrollLock.ts
utils/getDisabledMountTransitionStyles.ts+utils/constants.ts+utils/closePart.ts(ClosePartProvider/count/registration) +utils/floating/useHoverFloatingInteraction.ts(stub, popup side of hover). Then the 8 open-path parts insrc/popover.ts:PopoverPortal,PopoverPositioner(useAnchorPositioning + usePositioner + FloatingNode + InternalBackdrop + trigger-change animation gate),PopoverPopup(FloatingFocusManager + closePart + useOpenChangeComplete),PopoverArrow,PopoverBackdrop,PopoverTitle,PopoverDescription,PopoverClose. The open modal Popover is byte-identical to real Base UI — including the@floating-ui/dom-computed positioner styles (transform,--available-width/height,--anchor-*,--transform-origin), the modal backdropclip-pathcutout, popuprole=dialog+aria, arrow, and title/desc/close. NO octane fix needed — the positioning engine reuse just worked. Two rig-blind-spot notes (differential is final-HTML only, documented to not capture focus/effect-timing): (1) non-modalFloatingPortal.disableFocusInsidestashestabindex→data-tabindexon a focusout event React's jsdom mount sequences differently — so the differential fixture ismodal(disables that path, like Dialog); (2) with a trigger, itselement↔[guard,element,guard]shape-flip on open remounts the button, which the positioner observes as a transient "trigger change" (data-instant) — so the differential fixture is controlled-openWITHOUT a trigger (stable anchor). The trigger open→dismiss flow (Close + Escape) is covered by thePopoverInteractivebehavior test instead. Deferred: the open-on-hover interaction (both stubs) remains off-by-default.
Phase 3 — POPOVER started: CLOSED path landed + differential-verified (2026-07). Green: 52 base-ui tests, full suite 2277 green.
src/popover.ts—PopoverStore(extends the octaneReactStore; more state than Dialog: disabled/instantType/openChangeReason/stickIfOpen/openOnHover/ closeDelay/hasViewport/focusManagerModal + hover/patient-clicksetOpen),PopoverHandle/createPopoverHandle, contexts,PopoverInteractions(useDismiss + usePopupInteractionProps),PopoverRoot(wraps aFloatingTreeat top level),PopoverTrigger(useClick + trigger focus-guards + hover). Reuses the ENTIRE Dialog foundation. New:utils/popups/useTriggerFocusGuards.ts(ported — tab-out focus guards around an open trigger). Deferred stubs:utils/floating/useHoverReferenceInteraction.ts(returns{}; the ~1400-line hover/safePolygon open-on-hover feature is off by default) — click-to-open is fully functional. Uses the same stable-descriptor Provider-children workaround as Dialog. Next: the OPEN path — the Positioner (the big new surface:useAnchorPositioning~755 +usePositioner+adaptiveOriginanchor positioning via@floating-ui/dom) + Popup + Arrow + Backdrop + Title/Description/Close, then an open-popover differential (positions are jsdom-default but structure/styles must match) + focus/ dismiss behavior tests.
Phase 3 — DIALOG COMPLETE (2026-07). Green: 51 base-ui tests (49 differential + 2 behavior), full monorepo suite 2276 green. Ported Base UI's own
data-base-ui-*floating focus/portal layer toutils/floating/:tabbable.ts(+composite.ts,activeElement),FocusGuard.ts,FloatingPortal.ts(+PortalContext/useFloatingPortalNode),markOthers.ts,enqueueFocus.ts,FloatingFocusManager.ts(~600 lines octane; store-connected; focus trap + return + markOthers inert), and element/event/nodes/platform additions (isTypeableCombobox/getFloatingFocusElement/isVirtualClick/isVirtualPointerEvent/getNodeAncestors;platform.env.jsdom/os.android).dialog.tsnow imports the LOCAL FloatingPortal + FFM (fed the FloatingRootStore directly ascontext), so the open modal Dialog (Portal → InternalBackdrop + Backdrop + focus-guard + popuprole=dialog+ title/desc/close + focus-guard, alldata-base-ui-*withmarkOthersinert siblings) is byte-identical to real Base UI. Two id fixes for parity: the portal-node id +floatingIduse RAWuseId(nobase-ui-prefix, matching@base-ui/utils/useId), and the popup carriesstyle="--nested-dialogs: 0". Added dedicated behavior tests (non-differential — focus/close aren't in innerHTML): trigger opens, Close button + Escape dismiss. octane bug found + worked around (see below). Also fixed a flaky pre-existing radix Toast timing test (duration: 50racingsettle()'s real timers under load → widened to 500/700). Next: Popover (reuses ALL of this — positioner + anchor positioning is the new surface), then Tooltip/PreviewCard/AlertDialog/Menu/Toast.octane bug (Provider children shape-flip, UNFIXED — binding worked around): a context Provider whose
childrenprop ALTERNATES between a compiled render-body function and an element descriptor across renders crashes octane's reconciler ("Cannot read properties of undefined (reading 'items')") —childrenAsBodyruns a function child directly in the Provider scope (owningscope.slots) but a descriptor child viachildSlot(scope, 0), colliding the slot namespaces. Safe octane fix is a shape-flip reset inProviderBody(core teardown — deferred). Binding workaround: keep the Provider's children a STABLE descriptor shape both states (a no-DOMDialogInteractions/DialogChildrenwrapper). Full detail in memoryoctane-provider-children-shape-flip.
Historical Phase 3 OPEN checkpoint (part 2, 2026-07) — all Dialog parts + a functional open dialog landed; byte parity was temporarily blocked on Base UI's FloatingPortal + FFM. Built the real
DialogInteractions(wiresuseDismiss+useScrollLock+usePopupInteractionProps+ nested-dialog bookkeeping) and all remaining parts:DialogPortal(+DialogPortalContext,InternalBackdrop,inertValue),DialogBackdrop,DialogPopup,DialogTitle,DialogDescription,DialogClose. AdefaultOpenmodal dialog now renders fully — portaled, backdrop + popup(role=dialog, aria-labelledby/describedby) + title/description/close + focus guards — and functionally focus-traps/dismisses. KEY FINDING (corrects last turn's assumption):@octanejs/floating-uiemitsdata-floating-ui-*attributes (portal/focus-guard/ inert) + a different FocusGuard style (clipvsclip-path) + role + container handling, whereas Base UI emitsdata-base-ui-*. The repository subsequently ported the Base UI-specific portal and focus-manager behavior; the open Dialog differential now executes normally and passes with byte-identical markup.
Phase 3 OPEN path (part 1) — the store-connected dismiss/scroll layer landed (2026-07). base-ui typecheck + suite (48) green.
utils/floating/useDismiss.ts— the full store-based dismiss hook (Escape + outside-press close, with the complete intentional/sloppy press-type + touch + nested-tree logic; readsstore.useState/select/setOpen/context; returns{reference, floating, trigger}prop bags; native events; slot-threaded).utils/useScrollLock.ts(the ref-countedScrollLockersingleton + overlay/inset-scrollbar strategies, near-verbatim). New util deps:mergeCleanups,floating/createAttribute(data-base-ui-${name}),floating/nodes(getNodeChildren),floating/elementadditions (isEventTargetWithin/isRootElement/containsre-export),floating/eventadditions (isReactEvent),platformextended (engine.webkit,screenReader.voiceOver),AnimationFrame.create(). Added@floating-ui/utilsto base-ui deps (Base UI imports the SAME package forgetComputedStyle/getParentNode/isElement/isHTMLElement/isLastTraversableNode/isShadowRoot/isOverflowElement). Next (OPEN path part 2): the realDialogInteractions(wires useDismiss + useScrollLock), the FFM/FloatingPortal reuse-adapter (feed@octanejs/floating-ui'sFloatingFocusManager/FloatingPortala store-derived context — the plan's "reuse with adapters" path), the Portal/Backdrop/Popup/Title/Description/Close parts, then an open-dialog differential + focus-trap/return-focus/dismiss tests.
Phase 3 — FIRST OVERLAY (closed Dialog) landed + differential-verified (2026-07). Green: 48 differential tests, full monorepo suite 2175 green. The Store-based popup foundation is now proven end-to-end.
src/dialog.ts— the CLOSED-state path:DialogStore(extends the octaneReactStore; dialog-specific state/selectors +setOpen),DialogHandle/createDialogHandle,DialogRootContext/IsDrawerContext,useDialogRoot(uses the popups engine'susePopupRootSync
useImplicitActiveTrigger+useOpenStateTransitions+useImperativeHandle— NOTuseDismiss),useRenderDialogRoot(rendersDialogInteractionsonly whenopen || mounted),DialogRoot, andDialogTrigger(a<button>viauseRenderElement+useClick+useTriggerDataForwardinguseButton+useOpenMethodTriggerProps). New floating pieces:useClick.ts(store-connected, ~130 lines),element.ts/event.tsadditions (isTypeableElement/isMouseLikePointerType),useEnhancedClickHandler.ts,useOpenInteractionType.ts,popupStateMapping.ts(triggerOpenStateMapping).DialogInteractionsis STUBBED (returns null) pending theuseDismiss/FloatingFocusManager/FloatingPortallayer — it's only rendered when open, so the CLOSED differential is fully faithful (React also doesn't render it closed). octane fix (bug #2 — children-block detection): octane compiles a component's element/text children to a render function but passes a render-prop child ({(x) => …}) RAW — both aretypeof === 'function', so Base UI'schildren-as-payload-render-function API couldn't be distinguished. FIXED IN OCTANE: the compiler now tags compiled children-blocks (markChildrenBlock) and a new publicisChildrenBlock(value)excludes them, so the binding writestypeof children === 'function' && !isChildrenBlock(children)to detect a genuine render-prop child (payload render functions now work). Regression:packages/octane/tests/children-block.test.ts; changeset.changeset/is-children-block.md. Next: the OPEN path — portuseDismiss(~754), the store-basedFloatingFocusManager(~991),FloatingPortal(~307),useScrollLock, the realDialogInteractions, and the Portal/Backdrop/Popup/Title/Description/Close/Viewport parts, then an open-dialog differential + focus-trap/return-focus/dismiss tests.
Phase 3 STARTED — the overlay foundation (part 1) landed (2026-07). base-ui typecheck green. Key architectural discovery: Base UI 1.6.0 forked its vendored
floating-ui-reactto be Store-based. The overlays no longer consume upstream-shaped@floating-ui/reacthooks — they consume a reactiveFloatingRootStore+ store-connecteduseDismiss/useClick/useFocus/useClientPoint(which return{reference, trigger, floating}prop bags), plus a shared ~1231-lineutils/popupsstore engine, all built on aStore/ReactStoresystem (@base-ui/utils/store). This invalidates the plan's original assumption that@octanejs/floating-ui(an upstream-@floating-ui/reactport) is the drop-in overlay substrate. Decision (user-approved): port Base UI's Store-based floating layer faithfully, reusing from@octanejs/floating-uionly what is shape-compatible (safePolygongeometry; possiblyFloatingFocusManager/FloatingPortal/FloatingTreewith adapters). This makes Phase 3 a genuinely multi-turn effort: ~2500 lines of interlocking Store foundation must land before ANY overlay renders/tests.Foundation part 2 landed (2026-07, all typecheck-green, not yet imported by a component): the popups store engine + floating tree.
utils/popups/store.ts(thePopupStoreStateshape +popupStoreSelectors+createInitialPopupStoreState+createPopupFloatingRootContext, shared by every popup),utils/popups/popupStoreUtils.ts(the ~500-line engine —usePopupStore,useTriggerRegistration,useTriggerDataForwarding,useImplicitActiveTrigger,useOpenStateTransitions,usePopupInteractionProps,usePopupRootSync,applyPopupOpenChange,setPopupOpenState,FOCUSABLE_POPUP_PROPS— every hook slot-threaded;ReactDOM.flushSync→ octaneflushSync).utils/floating/FloatingTree.ts+FloatingTreeStore.ts(nested-popup tree:useFloatingParentNodeId/useFloatingTree/useFloatingNodeId+FloatingNode/FloatingTreecomponents),useSyncedFloatingRootContext.ts(keeps aFloatingRootStoresynced to a popup store). Small utils:empty.ts(EMPTY_OBJECT/ARRAY),dom.ts(isElement/isHTMLElement),useOnFirstRender.ts,floating/constants.ts(FOCUSABLE_ATTRIBUTE),floating/event.ts(isClickLikeEvent);REASONSgained the popup reasons (triggerHover/triggerFocus/outsidePress/ closePress/focusOut/escapeKey/imperativeAction). The store + popups-engine foundation — the hardest architectural part — is now complete. What remains before Dialog: the store-connected floating interaction/focus/portal layer (useDismiss~754,FloatingFocusManager~991,FloatingPortal~307,useScrollLock), then Dialog.Landed earlier (foundation part 1, all typecheck-green):
- Store system (
utils/store/):Store.ts(verbatim observer store),createSelector.ts(verbatim runtime),useStore.ts(octane-adapted — a ref-cached selection over octane's realuseSyncExternalStore, the same trick as@octanejs/zustand/traditional, no concurrent-mode shim),ReactStore.ts(octane-adapted — every hook-bearing method threads an explicit slot:useState(key, slot, …),useSyncedValue(key, value, slot),useControlledProp,useContextCallback,useStateSetter;useIsoLayoutEffect→ octaneuseLayoutEffect; dev warnings dropped).- Floating store layer (
utils/floating/):FloatingRootStore.ts(the Store-based root context, extendsReactStore),getEmptyRootContext.ts,createEventEmitter.ts,event.ts(isClickLikeEvent),types.ts(FloatingRootContext/Context/Events/ElementProps subset); plusutils/popups/popupTriggerMap.ts(PopupTriggerMap, verbatim).Remaining Phase-3 foundation (next turns), by size: the store-connected interaction hooks (
useDismiss~754,useClick~226,useFocus~250,useClientPoint~260),FloatingFocusManager~991,FloatingPortal~307,FloatingTree~95, and theutils/popupsengine (popupStoreUtils~512,inlineRect~292,useTriggerFocusGuards~95,store.ts~224). Then Dialog (~1400: Root/Trigger/Portal/Backdrop/Popup/Title/Description/Close/Viewport + its Store/Handle +useDialogRoot/DialogInteractions) as the first testable overlay (differential mount + open/close + a dedicated focus-trap/return-focus/dismiss test). Then Popover/Tooltip/PreviewCard/ AlertDialog/Menu(+Context/Menubar)/Toast.
Phase 2 COMPLETE — Slider DONE (2026-07). Green: 47 differential tests, full monorepo suite 2155 green.
src/slider.ts— the last Phase-2 giant, all 7 parts + 9 pure utils:Slider.Root(value/format state machine overuseControlled+ a sortedvaluesarray;setValueclones the event to exposeevent.target.valuefor form libs; wraps its<div role="group">in the compositeCompositeListso thumbs self-register),Slider.Control(the full pointer/drag finger-tracking + thumb-collision engine — inert in jsdom but ported faithfully),Slider.Track,Slider.Indicator(centered/inset fill %),Slider.Thumb(a<div>+ nested<input type="range">,useCompositeListItemregistration, per-thumb aria-valuetext, the full arrow/Page/Home/End keyboard state machine viagetNewValue/handleInputChange),Slider.Value(<output>with a multi-inputhtmlForderived from the thumb map),Slider.Label(root-label id association viauseLabel). New pure utilsutils/slider/{asc,replaceArrayItemAtIndex, getSliderValue,roundValueToStep,valueArrayToPercentages,getMidpoint,validateMinimumDistance, getPushedThumbValues,resolveThumbCollision}(all ported verbatim); new helpersuseIsHydrating(client → false),resolveAriaLabelledBy/getDefaultLabelId,matchesFocusVisible(jsdom-true),createGenericEventDetails,REASONS.{trackPress,drag},PAGE_UP/PAGE_DOWN. Key octane finding: a controlled range input reflects its live value to thevalueATTRIBUTE (verified vs React), so — unlike a controlled TEXT input (NumberField) — octane's native attribute write matches with NO freeze/property adaptation. With the defaultcenteralignment, thumb/indicator positions are pure math, so mount AND keyboard stepping (arrow keys re-renderaria-valuenow+ the%positions + the<output>text + the value attribute) are all byte-verified. Added akeydown(selector, key)helper to the shared differential rig (_rig.ts) to drive this. Pointer drag needs real layout → inert in jsdom, so not differential-covered (documented blind spot). Phase 2 done: Field/Form + Checkbox/CheckboxGroup/Switch/Radio/RadioGroup + NumberField + Input + Slider. Next: Phase 3 (overlays on@octanejs/floating-ui).
Phase 2 (in progress) — NumberField CORE DONE (2026-07). Green: 43 differential tests, full monorepo suite 2155 green.
src/number-field.ts— the first of the two Phase-2 giants. The value/format state machine ported faithfully:NumberField.Root(+NumberFieldRootContext,useControlledvalue,useForcedRerendering,setValue/incrementValue/getStepAmount/getAllowedNonNumericKeysviauseStableCallback,formatNumber-basedinputValuestate, and a hidden<input type="number">for form submission),NumberField.Group(role="group"),NumberField.Input(<input type="text">with the full onInput/onKeyDown/onBlur/onFocus/onPaste handler set: locale-aware numeral filtering, arrow/Home/End stepping, blur re-format, paste parse), andNumberField.Increment/Decrement(useNumberFieldStepperButton+useButton,focusableWhenDisabled, boundary-disabled at min/max). New utils:utils/number/{parse,validate, constants,types}.ts(parse/validate ported verbatim),useForcedRerendering,addEventListener,platform, andREASONSgained the number-field reason strings. octane adaptations: native events (no.nativeEvent); the text-input value adaptation applied to BOTH the visible input and the hidden number input (initial value → thevalueATTRIBUTE; live value driven via the.valuePROPERTY in a layout effect). The visible Field and NumberField text controls consume nativeinputper edit. The visually hidden, form-facing number host deliberately retains nativechange: an explicit change commit updates the root and visible formatted field, pinned bynumber-field.test.ts. Its non-serializedsuppressNativeChangeWarninghint records that local intent without changing event delivery. Deferred (stubbed):usePressAndHoldauto-repeat (single-click stepping works; hold-to-repeat inert) + the ScrubArea. Increment/decrement value changes are invisible ininnerHTML(React drives the value via the property too), so the differential gates the formatted-value render + the boundary-disabled state; value-change behavior is covered by the parse/validate ports and the handler logic. Remaining Phase 2: usePressAndHold auto-repeat + ScrubArea (NumberField polish) + Slider (~2835 lines) — the last large dedicated item.
Phase 2 (in progress) — CheckboxGroup DONE (2026-07). Green: 41 differential tests.
src/checkbox-group.ts— arole="group"whose child<Checkbox.Root>s derivecheckedfrom a shared value array; the previously-dormant parent-checkbox branches incheckbox.tsare now wired viauseCheckboxGroupParent(a 3-state select-all parent: indeterminate when only some children are ticked). New utils:areArraysEqual,useCheckboxGroupParent. Remaining Phase 2: NumberField (~2600 lines) + Slider (~2835 lines) — each a mini-subsystem (value/format state machine + pointer scrub/drag) whose architecture is mapped; they are the two large dedicated items left.
Phase 2 (in progress) — the Field/Form validation SYSTEM + Input DONE (2026-07). Green: 39 differential tests vs real
@base-ui/react@1.6.0, base-ui typecheck clean. This is the densest Phase-2 subsystem (~1800 lines):
- Field (
src/field.ts):Field.Root(+ the realFieldRootContextviauseFieldValidation— the native-constraint + custom async validation state machine — anduseFieldControlRegistration),Field.Control,Field.Label,Field.Description,Field.Error(transition-mounted),Field.Validity(render-prop),Field.Item. The realLabelableProvider(src/utils/field/) drives the label↔control↔description id association (for/aria-labelledby/aria-describedby) — verified byte-identical.- Form (
src/form.ts):<form noValidate>+ the realFormContext(field registry, submit-time validation, first-invalid focus). Input (src/input.ts) =<Field.Control/>.- octane text-input adaptation: the initial value is the
valueATTRIBUTE, a controlled value is driven via the.valuePROPERTY (mirrors the checkbox adaptation).Binding bug fixed (my
useRenderElementport, not octane): theenabled: falsepath assignedoutProps = EMPTY_OBJECT(shared module const) then mutatedoutProps.ref, poisoningEMPTY_OBJECT.refwith a stale composed-ref callback. A later component rendering with the DEFAULT (no-state) EMPTY_OBJECT then emitteddata-ref="<fn>"viagetStateAttributesProps. Surfaced by a differential test-ordering probe (RadioGroup's grouped Radio usesenabled:false→ then Form failed). Fix: run the composed-refs hook for slot stability but only assignoutProps.refwhen enabled, returning EMPTY_OBJECT untouched.
Phase 2 (in progress) — Checkbox + Radio + RadioGroup DONE (2026-07). Green: 35 differential tests vs real
@base-ui/react@1.6.0. The boolean/choice-control family is complete (Switch, Checkbox, Radio, RadioGroup), all reusing the octane uncontrolled-input adaptation
- the field-context infrastructure:
- Checkbox (
src/checkbox.ts):Root+ transition-mountedIndicator; indeterminate (aria-checked="mixed"+input.indeterminateproperty). Group/parent-checkbox branches dormant until CheckboxGroup.- Radio (
src/radio.ts) + RadioGroup (src/radio-group.ts): RadioGroup renders arole="radiogroup"via CompositeRoot (reusing the Phase-1 roving-focus system), each Radio a CompositeItem derivingaria-checkedfrom the group value; the selected radio holds the active tab stop (data-composite-item-active). New small utils:serializeValue,contains,FieldItemContext,getDefaultFormSubmitter,CheckboxGroupContext/RadioGroupContext.- Click interactions verified byte-identical (toggle, selection-move).
Phase 2 (in progress) — Field/Form context infrastructure + Switch DONE (2026-07). Green: 29 differential tests vs real
@base-ui/react@1.6.0(3 new Switch: uncontrolled toggle, default-checked, disabled — all with click interaction), base-ui typecheck clean.
- Field/Form context infrastructure (
src/utils/field/+src/utils/{owner,useValueChanged,noop}.ts): the context surfaces every form control threads through, ported with Base UI's DEFAULT values so controls work standalone (inert validation):FieldRootContext(+DEFAULT_FIELD_ROOT_CONTEXT),FormContext,LabelableContext,field/constants(DEFAULT_FIELD_ROOT_STATE/fieldValidityMapping/FieldValidityData), and the consumer hooksuseRegisterFieldControl,useAriaLabelledBy,useLabelableId,useValueChanged. The fullField.Root/FormPROVIDERS (which override the defaults + run validation) land later this phase; the controls are differential-tested standalone first.- Switch (
src/switch.ts) —Switch.Root(role="switch"span + hidden checkbox input)
Switch.Thumb. ReusesuseButton/useControlled.Reversal (2026-07-08): octane now ships React-parity controlled components (
value/checkedreassertion on native events; still no syntheticonChange—onInput/nativechange/clickdrive updates). The adaptation below is obsolete — form controls pass real controlled props directly and the imperative property-setting machinery is being removed. Kept for the historical record.octane uncontrolled-input adaptation (the Phase-2 crux, reusable by Checkbox/Radio/etc.): octane inputs are UNCONTROLLED (a
checkedprop writes acheckedATTRIBUTE), but React's controlled<input checked>reflects only the INITIAL checked to the attribute (as its default-state) and drives the live value via the.checkedPROPERTY. So the port: (1) passeschecked: initialCheckedRef.current || undefined(the initial state → attribute), (2) drives the liveinput.checkedPROPERTY imperatively via the nativeHTMLInputElement.prototypesetter in a layout effect, and (3) the root'sonClickdispatches a nativeclickon the hidden input → nativechange→onChange(octane delegateschangefor de-optcreateElementinputs — confirmed). This mirrors the proven@octanejs/radixbubble-input pattern; it is a binding adaptation to octane's documented uncontrolled-input divergence, not a workaround for a bug. Verified byte-identical incl. the click-toggle interaction.
Phase 1 COMPLETE — ToggleGroup + Avatar DONE (2026-07). Green: 26 differential tests vs real
@base-ui/react@1.6.0, base-ui typecheck clean, full monorepo suite green. All Phase-1 components shipped: Separator, Fieldset, Meter, Progress, Toggle, ToggleGroup, Avatar.
- ToggleGroup (
src/toggle-group.ts) + Toggle's group path — required porting Base UI's entire composite roving-focus system (src/utils/composite/):CompositeRoot+useCompositeRoot(arrow/Home/End keyboard nav, default tab stop),CompositeList+useCompositeListItem(stable-Map registration → document-order index + MutationObserver),CompositeItem+useCompositeItem(rovingtabIndex0/-1 + focus/hover), plus vendored floating-ui list utils (list-utils.ts),keys.ts(nav constants +scrollIntoViewIfNeeded), a minimalDirectionContext, anduseRefWithInit. Decision: ported Base UI's composite directly rather than bridging to@octanejs/floating-ui'sComposite(different API + behavior would break byte-parity). Differential tests: single-select (roving tabindex + value→aria-pressed + click moves value), multiple-select (data-multiple), disabled group. This unlocks Toolbar / Menu / Menubar / Select / NavigationMenu / Tabs / RadioGroup for later phases.- Avatar (
src/avatar.ts) — Root/Image/Fallback + the transition system:useTransitionStatus(+transitionStatusMapping→data-starting-style/data-ending-style),useOpenChangeComplete→useAnimationsFinished→useAnimationFrame/resolveRef,useImageLoadingStatus(off-DOMnew Image()load tracking),useTimeout. Under jsdom the image never resolves, so (identically on both renderers) the<img>stays unmounted and the Fallback shows — verified<span class="av"><span class="av-fb">JD</span></span>.Internals now available for Phase 2+: the composite system, the transition/animation system,
useButton/useControlled/useFocusableWhenDisabled,useStableCallback/useValueAsRef/useRefWithInit/useTimeout,useBaseUiId/useRegisteredLabelId,DirectionContext.
Phase 1 (in progress) — Meter + Progress + Toggle DONE (2026-07). Green: 21 differential tests vs real
@base-ui/react@1.6.0(Separator ×5, useRender ×2, Fieldset ×4, Meter ×3, Progress ×3, Toggle ×4), base-ui typecheck clean, full monorepo suite 1497 green.
- Meter (
src/meter.ts): Root (role="meter", range math)/Track/Indicator/Value/Label. Proves the multi-part context + derived-state + style-object serialization parity with React (visuallyHidden,insetInlineStart,width:40%all byte-identical).- Progress (
src/progress.ts): adds thestatusstate ('indeterminate'|'progressing'|'complete') via a customstateAttributesMapping→data-progressing/data-complete/data-indeterminateon every part; indeterminate (value={null}) omitsaria-valuenow+ empties the fill. UsesuseValueAsRef(format).- Toggle (
src/toggle.ts): a two-state<button>(type="button",aria-pressed). Differential test drives real clicks — uncontrolled toggle flips byte-identically to React across clicks; disabled + controlled are no-ops. The group path (CompositeItem) throws pending ToggleGroup; standalone is complete.Reusable internals layer built (all slot-threaded plain-
.ts, faithful ports):utils/useBaseUiId,useRegisteredLabelId,valueToPercent,clamp,stringifyLocale,formatNumber/formatNumberValue,visuallyHidden,useValueAsRef(≈ floating-uiuseLatestRef),useStableCallback,useControlled,useFocusableWhenDisabled,CompositeRootContext(stub — undefined until the composite system lands),useButton(native-event adaptation:makeEventPreventableon the native event),createChangeEventDetails
REASONS,ToggleGroupContext. Dev-warning surfaces dropped per the port policy.
Phase 1 (in progress) — Fieldset DONE (2026-07). Green: 11 differential tests (4 new: basic aria-labelledby wiring, disabled, explicit-id, legend render-prop).
src/fieldset.ts—Fieldset.Root(<fieldset>,disabledstate +data-disabled, provides a plain octane context) andFieldset.Legend(<div>, generated id via the net-newsrc/utils/useBaseUiId.ts= octaneuseId+base-ui-prefix; a layout effect feedslegendIdback to the Root asaria-labelledby). Base UI uses a PLAIN React context (not the scoped factory) → ported as a plaincreateContext+Provider(createElement(Ctx.Provider, …)) + throwing consumer.octane bug #1 — fixed in octane (compiler), not worked around. A component root that PRECEDES a static host root in a multi-root fragment body rendered in REVERSED order. Base UI's Fieldset hits this because a Root's children (
[<Legend/>, <control/>]) thread throughuseRenderElement→createElement('fieldset', { children }), and the.tsrxcompiler lowers those children to a fragment render-fn. The fragment-body codegen (planJsx/emitElementHtmlincompiler/compile.js) dropped the component root's source-order<!>anchor, so the static content drained first and the component appended atendMarkerAFTER it — also a client/server divergence (the server emitted source order) that could mis-adopt on hydration. Fix: emit the<!>anchor for a component root in a mixed body, mirroring the in-element mixed-children path. Regression tests:tests/mixed-child-order.test.ts(client mount: static / value-position / effect-driven),tests/hydration/mixed-frag-hydrate.test.ts(server DOM adopted in place, no mismatch). Changeset.changeset/mixed-fragment-component-anchor.md(octanepatch). Full suite: 1488 tests green.
Phase 0 foundation — DONE (2026-07). Green: 7 differential tests, base-ui typecheck clean. Established the whole substrate: pinned
.base-ui/checkout (gitignored,v1.6.0), package scaffolding (package.json,tsconfig,internal.tsre-namespaced from radix, README), the catalog entry (@base-ui/react1.6.0), thebase-uivitest project (jsdom + differential precompile +octane()exclude forsrc/+floating-ui + subpath aliases), the root typecheck entry, and the differential harness (_setup.tsper-subpath rewrite@octanejs/base-ui/<sub>→@base-ui/react/<sub>; reuses octane'smountDifferential).The composition engine is ported and byte-verified — the make-or-break piece (
docs/radix-migration-plan.md:267rejected Base UI on the missing-clone blocker, now resolved):mergeProps/mergePropsN/mergeClassNames(src/utils/mergeProps.ts— with the octane adaptation: octane dispatches NATIVE events, sopreventBaseUIHandleris shimmed onto the native event instead of gated behind React'sisSyntheticEvent),useRenderElement(src/utils/useRenderElement.ts— the engine, over octanecloneElement/createElement/useComposedRefs; octane's no-rules-of-hooks drops Base UI's conditional-ref-hook dance), and the publicuseRender(src/use-render.ts) +merge-props(src/merge-props.ts). Supporting utils ported:resolveClassName,resolveStyle,mergeObjects,getStateAttributesProps,getElementRef, andcomposeRefs(copied from radix). First componentSeparator(src/separator.ts) passes differential parity in all forms — intrinsic-tag render, render-prop element (clones onto<hr>, className concatenates), render-prop function,classNameas a function of state,state→data-orientation— as doesuseRender(basic + function). Key finding: Base UI'srender-prop is prop-position (an element descriptor), which is octane's native shape — a better fit than Radix's children-positionasChild. No octane bugs surfaced yet.
Measured by diffing the pinned .base-ui/packages/react/src tree against
packages/base-ui/src. Upstream 1.6.0 is still the published latest, so the
pin is current. Baseline at audit time: 60 base-ui tests green, 22 of 43
published subpaths implemented, 19,614 ported source lines against 73,766
upstream source lines (tests excluded).
Superseded in part by Phases 3a–3d (see the progress log above): the subpath count is now
28 of 43, gaps B1 (Dialog.Viewport/Popover.Viewport) and C1 (openOnHover) are closed, andtooltip,preview-card,button,direction-provider,csp-providerandunstable-use-media-queryhave landed. The rest of the inventory below still stands.
| Component | Upstream loc | Blocking infrastructure |
|---|---|---|
combobox |
6,469 | list navigation, grid navigation, filter, itemEquality, resolveValueLabel |
drawer |
4,722 | useSwipeDismiss, getElementAtPoint, scrollable, viewport |
menu |
4,303 | list navigation, typeahead, useMixedToggleClickHandler, getPseudoElementBounds, viewport |
select |
4,220 | list navigation, typeahead, scrollEdges, styles, usePopupAutoResize |
navigation-menu |
3,106 | hover layer, getCssDimensions, setSharedFixedSize, isOutsideMenuEvent |
toast |
3,004 | useSwipeDismiss, FloatingPortalLite, focusVisible |
scroll-area |
1,745 | scrollEdges, getOffset, styles, CSPContext |
tooltip |
1,625 | hover layer, FloatingDelayGroup, FloatingPortalLite, viewport |
tabs |
1,537 | getCssDimensions, CSPContext (composite already ported) |
otp-field |
1,436 | utils/otp (field/composite already ported) |
preview-card |
1,363 | hover layer, FloatingPortalLite, viewport |
collapsible |
1,149 | collapsibleOpenStateMapping |
accordion |
961 | Collapsible |
autocomplete |
820 | Combobox (thin layer over it) |
toolbar |
638 | none — composite + useButton already ported |
context-menu |
433 | Menu, useClientPoint |
menubar |
217 | Menu |
unstable-use-media-query |
90 | none |
button |
82 | none — useButton + useRenderElement already ported |
direction-provider |
72 | none — DirectionContext already ported |
csp-provider |
47 | CSPContext |
Dialog.ViewportandPopover.Viewport— both needutils/usePopupViewport(376) +utils/popups/inlineRect(293). The same pair also gates the Viewport part on Tooltip / PreviewCard / Menu / NavigationMenu / Drawer.NumberField.ScrubAreaandNumberField.ScrubAreaCursor.
utils/floating/useHoverReferenceInteraction+useHoverFloatingInteractionreturn{}, soopenOnHoveron Popover is a no-op. Un-stubbing needs the whole hover layer (below).utils/usePressAndHoldfires once, so NumberField Increment/Decrement hold-to-repeat is inert. Single clicks work.
These are the only places where the port accepts a prop and does nothing; both are documented in-file, but neither is covered by a failing-pin test.
floating-ui-react (3,704 loc, gates every remaining overlay):
useListNavigation (931), useHover (468), safePolygon (452),
FloatingDelayGroup (287), useClientPoint (261), useTypeahead (254),
useFocus (251), useHoverInteractionSharedState (132), useHoverShared (73),
gridNavigation (51). middleware/arrow (124) is currently satisfied by
@octanejs/floating-ui's ref-aware arrow and needs no port.
utils (2,454 loc): useSwipeDismiss (1,209), usePopupViewport (376),
popups/inlineRect (293), usePopupAutoResize (239), scrollable (73),
useMixedToggleClickHandler (62), getPseudoElementBounds (50),
FloatingPortalLite (44), collapsibleOpenStateMapping (36), scrollEdges
(34), getCssDimensions (25), styles (13), getElementAtPoint (4).
internals (~690 loc): resolveValueLabel (152), composite/root/gridNavigation
(128), RequestQueue (127), filter (83), itemEquality (61), reason-parts
(43), TimeoutManager (30), CSPContext (24).
Out of scope: internals/temporal, internals/temporal-adapter-date-fns,
internals/temporal-adapter-luxon (~1,290 loc) are unreachable from any exported
component in 1.6.0 — groundwork for unreleased date components. Port them only
when an exported component consumes them.
- octane
Providerchildren shape-flip (memoryoctane-provider-children-shape-flip): every overlay Root currently pays a stable-descriptor wrapper workaround. Fix in octane core before Menu/Select/Combobox multiply it. - Differential rig blind spots: the rig compares final HTML only, so focus order, effect timing and hover/delay behavior need dedicated behavior tests. Every phase below must budget for them.
- SSR/hydration: only closed overlays and static components are covered. Open-overlay SSR is untested.
Ordered so that each phase unblocks the next, with the cheap export-surface wins
pulled forward. Every phase exits on: differential parity for the new components,
dedicated behavior tests for anything the rig cannot see, pnpm typecheck,
pnpm format:check, full pnpm test, a changeset, and a status.json update.
- Phase 3a — Zero-dependency backlog (DONE):
Button,DirectionProvider,CSPProvider(+CSPContext),unstable-use-media-query. ✅ - Phase 3b — Hover + focus interaction layer (DONE, ~2,400 loc):
safePolygon,useHoverShared,useHoverInteractionSharedState, the realuseHoverReferenceInteraction/useHoverFloatingInteraction,useFocus,FloatingDelayGroup. Stub C1 deleted. Upstream's standaloneuseHovercombiner was deliberately skipped — no component uses it, and this binding does not republish the vendoredfloating-ui-reactsurface. ✅ - Phase 3c — Popup viewport (DONE, ~700 loc):
usePopupViewport+usePopupAutoResize+FloatingPortalLite+getCssDimensions+usePreviousValue, thenDialog.ViewportandPopover.Viewport. Gap B1 closed. (popups/inlineRectmoved to 3d: it serves PreviewCard, not the viewport.) ✅ - Phase 3d — Tooltip + PreviewCard (DONE, ~3,000 loc), plus the
useClientPointandpopups/inlineRectthey depend on. ✅ - Phase 3e — List navigation + typeahead (~1,400 loc):
useListNavigation,gridNavigation(both copies),useTypeahead,RequestQueue,TimeoutManager,useMixedToggleClickHandler,getPseudoElementBounds. ✅ - Phase 3f — Menu family (~5,000 loc), in four stages:
- Stage 1 (DONE, ~1,750 loc):
MenuStore/MenuHandle,findRootOwnerId,Root,Trigger,Portal,Positioner,Popup— the open/close + roving-focus path, and the first point a differential test can render an OPEN menu. Exercises Phase 3e'suseListNavigation/useTypeahead. ✅ - Stage 2 (DONE, ~1,700 loc):
Item,CheckboxItem(+Indicator),RadioGroup/RadioItem(+Indicator),Group/GroupLabel,LinkItem,Separator,Arrow,Backdrop,Viewport, andmenu/utils/stateAttributesMapping. Differential on populated open menus; behavior tests for roving focus, typeahead (including buffer accumulation and reset), and checkbox/radio item semantics. ✅ - Stage 3 (DONE, ~340 loc):
SubmenuRoot+SubmenuTrigger. Differential on a closed submenu and on the open submenu's own portal subtree; behavior tests for submenu open/close, the child-only Escape, the trigger's dual item+trigger role, and nested placement. ✅ - Stage 4 (DONE, ~650 loc):
MenubarandContextMenu, which fill in the already-transcribedmenubar/context-menuparent branches and provide the two context-only modules stage 1 landed. ✅ Phase 3f complete.
- Stage 1 (DONE, ~1,750 loc):
- Phase 3g — Toast (DONE, ~2,850 loc): the store,
createToastManager,useToastManagerand the 11 Toast parts. Differential on added toasts (single, stacked, limited, high-priority); behavior tests for the imperative manager, auto-dismiss, hover pause/resume, loading + promise toasts, and the limit flag.useSwipeDismisswas NOT part of this phase — see the correction in the progress entry; it moved to Phase 6, its only consumer. ✅ - Phase 4 — Disclosure, navigation, composite (~6,000 loc), independent of
the floating store and parallelisable with Phase 3:
Collapsible(+collapsibleOpenStateMapping) →Accordion;Toolbar;Tabs(+getCssDimensions);ScrollArea(+scrollEdges,getOffset,styles). Exit: rig-green plus roving-focus/keyboard behavior tests. - Phase 5 — Selection giants (~13,000 loc):
Select(+itemEquality,resolveValueLabel,usePopupAutoResize), thenCombobox(+filter,handleInputPress,useInitialLiveRegionTextMutation,ComboboxInternalDismissButton), thenAutocompleteas a thin layer, thenOtpField(+utils/otp). Exit: differential on open lists, behavior tests for keyboard selection, typeahead, filtering, and Field integration. - Phase 6 — Drawer + residual (~6,500 loc):
NavigationMenu(+setSharedFixedSize,isOutsideMenuEvent),Drawer(+getElementAtPoint,scrollable, anduseSwipeDismissitself — 1,208 loc, moved here from 3g because Drawer is its only consumer), andNumberField.ScrubArea/ScrubAreaCursorwith the realusePressAndHold(deletes stub C2). - Phase 7 — Close-out: barrel export of the full surface, open-overlay SSR +
hydration coverage, README + divergence notes,
docs/bindings-status.mdrefresh, and a re-run of this audit to confirm 43/43 subpaths.
- Phase 3a and Phase 4 have no dependency on Phase 3b–3g and can run in parallel with them.
- Fix the octane
Providerchildren shape-flip before Phase 3f — Menu, Select and Combobox each add several Roots that would otherwise inherit the workaround. useSwipeDismiss(1,208 loc) is the single largest remaining util. The original note here claimed it served Toast and Drawer; porting Toast proved it serves ONLY Drawer (Toast borrows two pure helpers from the file), so it lands in Phase 6 alongside its consumer.
- Phase 0 — Foundation (DONE): scaffolding +
.base-ui+ engine + differential harness. Exit:useRender+ a trivial component byte-equal in the rig (element+function render, className string+fn,data-*state). ✅ - Phase 1 — Simple state / proof (DONE): Separator, Toggle, ToggleGroup, Avatar, Progress, Meter, Fieldset. Exit: rig-green; state exposure + render engine proven.
- Phase 2 — Field/Form + form controls (densest) (DONE): Field, Form, Checkbox, CheckboxGroup, Switch, Radio, RadioGroup, NumberField, Input, Slider. Validation system + octane uncontrolled-input adaptations. Exit: native-behavior parity; divergences documented.
- Phase 3 — Overlays (on
@octanejs/floating-ui) (PARTIAL — Dialog, AlertDialog, Popover done): Popover, Dialog, AlertDialog, Tooltip, PreviewCard, Menu, Menubar, ContextMenu, Toast. - Phase 4 — Navigation + composite + Select: Tabs, Accordion, Collapsible, Toolbar, NavigationMenu, ScrollArea, Select. Exit: rig + roving-focus/keyboard tests.
- Phase 5 — Long tail + polish: Autocomplete, Combobox; SSR/hydration; README +
divergence notes; changeset; parity-plan + memory. Exit: full
pnpm test/typecheck/ format green.
@octanejs/floating-ui— full@floating-ui/reactport; Base UI's entire positioning/interaction/focus/portal substrate (Phase 3+). Depend on it like radix does.packages/radix/src/helpers (copy-by-value, re-namespaceS):internal.ts,compose-refs.ts,use-effect-event.ts,useControllableState.ts,useId.ts,direction.ts,context.ts,Presence.ts,Portal.ts,FocusScope.ts,DismissableLayer.ts,collection.ts,scroll-lock.ts,use-size.ts.Form.tsis the validation reference for Field/Form.- octane runtime:
cloneElement/Children/isValidElement/normalizeClass.
- Native events, not synthetic (
preventBaseUIHandlershimmed onto the native event). forwardRef→ ref-as-prop.classNamecomposition via octane'snormalizeClassat the apply site; the render-prop merge concatenates strings exactly like Base UI.- Base UI-specific dev warnings were omitted from this package migration's historical scope. Core Octane's progressive React/ReactDOM diagnostic parity policy does not automatically include diagnostics owned by an upstream binding.
Per phase: the base-ui vitest project (differential + unit) green; pnpm typecheck;
pnpm format:check. Differential parity vs real @base-ui/react is the gold
standard. Re-clone the source: git clone https://github.com/mui/base-ui .base-ui && git -C .base-ui checkout v1.6.0.