Fix/UI lifecycle a11y hardening - #11
Merged
Merged
Conversation
hexplus
added a commit
that referenced
this pull request
Aug 30, 2026
…ration, membership-aware validity Addresses the four remaining PR #11 review findings. 1. Prereleases never publish under npm's default `latest` The publish argv was always `npm publish --access public`, so a prerelease landed on the default dist-tag and would be served by `npm install sibujs-ui`. A git tag is not an npm dist-tag; the old test named "publishes a prerelease with an explicit tag" only asserted the git tag, and has been renamed to say so. npmDistTag() now derives the dist-tag from the first prerelease identifier (alpha/beta/rc/canary...), falling back to `next` when that identifier is numeric or literally "latest", since npm rejects a dist-tag that parses as a version. Stable versions publish `--tag latest` explicitly. The tag is logged before publishing and returned in the result. 2. Release rollback is genuine and verified `restoreFiles()` ran `git checkout -- package.json package-lock.json`, which restores the worktree *from the index* — after `git add` succeeded, the version bump stayed staged and the release was not actually undone. It now takes the validated revision and runs `git restore --source=<rev> --staged --worktree`, reverting index and worktree together. HEAD is also captured and validated (as a real commit id) *before* the gates and before any version mutation: without a verified rollback target the release refuses to start rather than proceeding un-undoable. rollback() returns {ok, failures} and every cleanup command result is checked. A failed cleanup raises stage "rollback" naming the original failure, the exact commands that failed, and the manual recovery steps — it never claims the repository was restored. A successful publish is still never rolled back automatically. 3. RadioGroupItem follows its current group An item bound its group context once inside deferOwned() and kept it for life, so an item mounted late got no ARIA state and no bridge, and an item moved from group a to b kept submitting under a. Items now expose an attach(ctx) handle returning an idempotent detach; the group owns a registry and reconciles membership, attaching joiners and detaching leavers. Bridges and effects are scoped (Owner.addScoped / scopedEffect / a cleanup handle from attachRadioBridge) so a binding can be replaced without disposing the item. Ownership is by nearest RadioGroup ancestor, so nested groups never claim each other's items. 4. Nameless required-group validity tracks membership Validity was computed by an effect depending only on the selection signal, while the MutationObserver updated only the roving tabindex — so removing or disabling the selected item left the group reporting valid. Reconciliation now drives item bindings, the roving tab stop and constraint validity from one membership model, and the validity bridge exposes refresh(). The valid selection is computed by comparing data-value across the group's own items rather than building an attribute selector out of consumer strings. Tests: 325 total. New suites pr11-final-repro.test.ts (17) and pr11-radio-registration.test.ts (15); 25 of them fail against ccca9ca. The rollback finding also has an isolated integration test using a temporary git repository that demonstrates `git checkout --` leaving the bump staged and proves `git restore --source=<rev> --staged --worktree` clears index and worktree. No test performs network access or publishes anything.
hexplus
added a commit
that referenced
this pull request
Aug 30, 2026
…ested isolation, disabled bridge sync Addresses the four remaining PR #11 core-review findings. Local changes only — nothing was published, tagged or pushed, and the release script was not run. 1. A failed `npm publish` no longer proves nothing was published The exit code was treated as proof, so a registry that accepted the tarball before the connection died would be followed by deleting the git tag, resetting the release commit and restoring the old version — leaving a live release with no matching repository state, and no way back because unpublishing is not an option. The publish now runs through `runDetailed`, a structured adapter reporting {started, ok, status, signal, error}, and the result is classified as not_started / confirmed_published / confirmed_absent / uncertain: - never started -> nothing reached the registry, rollback is safe; - clean exit -> continue; - ran and failed -> query `npm view <pkg>@<version>` once (never retry the publish) and let the registry decide. Confirmed present is treated as a success and keeps the commit and tag. Confirmed absent permits the verified rollback. Anything inconclusive — network, auth, DNS, timeout, empty or malformed output — raises a distinct `publish-uncertain` error that rolls nothing back, states the status is UNKNOWN, and gives separate recovery steps for "version exists" and "version does not exist". No path calls `npm unpublish`, and no message claims "Nothing was published" on the strength of an exit code alone. 2. Item binding no longer depends on MutationObserver ordering Observers fire in creation order, not DOM-operation order. With the destination group created first, it installed the new binding and the source group's observer then ran a stale detach that stripped the ARIA state, indicator and bridge the new binding had just installed. Each item now holds one authoritative binding identified by a token: attaching retires the previous binding immediately, and any later detach whose token is no longer current is a no-op. Both construction orders now produce identical results. 3. Nested RadioGroups are fully isolated `applyRovingTabIndex()` still used `el.querySelectorAll(ITEM_SELECTOR)`, claiming nested items and leaving the inner group with zero tab stops; the keydown handler also acted on bubbled events from nested groups, so ArrowRight inside the inner group invoked the outer `onValueChange`. Both now use nearest-group ownership (`item.closest(GROUP_SELECTOR) === el`). Propagation is left untouched, so consumer handlers still see the event. 4. Named bridges follow the effective disabled state Reconciliation skipped already-registered items, and the bridge captured `disabled` once at creation, so disabling a selected item left its native radio enabled and still submitting. Bindings now expose {detach, refresh}; reconciliation refreshes retained bindings, and the bridge takes a disabled *getter*. A disabled bridge is also unchecked, because a radio group's `required` constraint is satisfied by any checked member — a selected-but-disabled member would otherwise keep an empty required group looking valid while contributing nothing to FormData. Writes are guarded to the changed value so the bridge, which lives inside the observed subtree, cannot feed its own attribute changes back into reconciliation. Tests: 389 total (was 325). New suites pr11-core-repro.test.ts (12) and pr11-core-hardening.test.ts (52); 39 of them fail against 0671b8a. Every release test uses injected adapters that only record argv — no test contacts npm or any remote.
…utomated npm release subsystem in favour of manual publishing
hexplus
force-pushed
the
fix/ui-lifecycle-a11y-hardening
branch
from
August 31, 2026 23:56
e8fca70 to
c72de8d
Compare
…th manifests agree
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes six classes of defect across lifecycle management, modal scroll locking, reactive children, AlertDialog accessibility, form participation, and the release script.
Every fix was reproduced against the unmodified base first: the new regression suites produce 68 failures when run against
70143b1, and all pass on this branch.Base:
70143b1-> Head:0795f83| 46 files changed (+4405 / -780) | 224 tests (was 102)1. Reactive ownership and disposal
bindControlled()created aneffect()and threw away its teardown, so a controlled component stayed subscribed to its parent's signal forever - reading the getter and writing into detached DOM long afterdispose(). The same pattern appeared in ~35queueMicrotaskblocks across the component set, none of which guarded against the element being disposed before the microtask ran.bindControlled()now returns its teardown and accepts an owner node. Every caller registers it on the component's in-tree root.src/lib/lifecycle.tsprovidesnodeOwner()/ownedEffect()/deferOwned(): teardowns are collected per DOM node, run exactly once, and cover timers,requestAnimationFrame, observers and document/window listeners.deferOwned()skips setup entirely if the node was already disposed, closing the microtask race.DropdownMenuContent, sub-menus, portaledTooltipContent) anchors cleanup on the in-tree node -dispose()cannot reach a node that has been moved to<body>- and now disposes the portaled subtree before detaching it.signal(controlled ?? default)and could not accept a reactive getter at all. Migrated tobindControlled, wideningopen?: booleantoboolean | (() => boolean).ResizeObserverconstruction is guarded inaccordion.ts(it already was inscroll-area.ts), and ScrollArea'sscrollHideDelaytimer is now cancelled on disposal - it previously fired against detached DOM.2. Coordinated body scroll lock
Dialog, AlertDialog, Sheet and Drawer each did
document.body.style.overflow = "hidden"/""independently. Mounting an initially closed modal erased the page's existing overflow; closing one modal unlocked the page while another was still open; disposal restored""rather than the original value.Replaced with a reference-counted
createScrollLock()insrc/lib/scroll-lock.ts:hidden.3. Reactive children preserved
toNodes()returnsNode[], and a() => NodeChildgetter has noNoderepresentation - so it was silently dropped.DialogContent(() => "Reactive child")rendered nothing.Added
toChildren(): NodeChild[], which passes getters straight through to the SibuJS tag factory. The framework binds and disposes them itself, so there is no placeholder element, no manualeffect(), and noinnerHTML.All 25 call sites migrated, including the duplicate helper in
command.ts, which was deleted. Ordering is preserved around internal nodes that components prepend or append (Drawer handle, Dialog close button, menu chevrons, Command group heading). Nested arrays flatten in order;null/undefined/ booleans are ignored; nothing is stringified.toNodes()keeps its exactNode[]signature for backward compatibility and is deprecated with a dev-mode warning - no breaking change.4. AlertDialog accessible labelling
AlertDialogContentreferencedaria-labelledby="alert-dialog-title", but no element ever carried that id, and two dialogs would have collided outright.Per-instance ids are now generated with
createId(), shared through the AlertDialog context, and adopted by that instance's own title and description. An explicitly supplied user id wins, and the content is re-pointed at it so the reference still resolves. A reference that resolves to nothing - because the title or description is intentionally absent - is removed rather than left dangling.5. Checkbox, Switch and RadioGroup participate in forms
These exposed
name/value/requiredbut renderedtype="button"controls, producing no entries innew FormData(form)and taking no part in constraint validation.Each is now paired with a visually hidden native input (
src/lib/form-control.ts) that mirrors its state - deliberately notdisplay: noneortype="hidden", both of which break constraint validation or makerequireda lie.name;valuedefaults to"on"as a native checkbox does.disabledexcluded per spec;requiredenforced by realcheckValidity().form.reset()restores the default and the visible control follows.aria-hiddenwithtabindex="-1"- no duplicate tab stop, no duplicate form entry - and is removed on disposal.Switchgains the missingvalueandrequiredprops.RadioGroupimplements the native radio keyboard model: a single roving tab stop, wrapping Up / Down / Left / Right, and Home / End, all skipping disabled items.A
name(orrequired) is what opts a control in; without either, no hidden input is created.6. Release script fails closed
publish.mjsignored lint failure, ran no tests or type-check, updated onlypackage.json(leavingpackage-lock.jsoninconsistent), ignored git command results, created a commit and tag before discovering a publish problem, and printed success unconditionally. Its rollback was a literal no-op - it re-read the already-mutated file into a local object that was never written back.Orchestration moved to
scripts/release-core.mjswith every side effect injected, so ordering, aborts and rollback are testable without ever publishing or pushing.npm ci,npm run lint,npx tsc --noEmit,npm test -- --run,npm run build,npm pack --dry-run. A failure aborts with the repository untouched.npm version --no-git-tag-version, keeping both files consistent and leaving tagging explicit.--forceis never used.release: vXcommit survives. A failed push after a successful publish reports the exact recovery command and does not unpublish.Also adds
.github/workflows/ci.ymlrunning install, lint, type-check, test and build on PRs and pushes tomainacross Node 22.3, 22 and 24 (SibuJS 4 requires>=22.3.0), plus a production-dependency audit.package.jsonnow declaresengines.node.Documentation
sibujs >= 1.3.0to the actual>=3.2.0 <5.0.0, verified by running the full suite and type-check against3.2.0,3.4.1and4.0.1.1.3.0wording removed."sideEffects": false). Accessibility section states what is wired without claiming WCAG conformance.Test plan
npm cinpm run lintnpx tsc --noEmitnpm test -- --runnpm run buildnpm pack --dry-runnpm audit --omit=devgit diff --checkgit status --shortSuite run 8 times including shuffled order and
--no-file-parallelism(shared process) - 224/224 every time, no cleanup-related cross-test pollution.New suites:
lifecycle-disposal,scroll-lock,reactive-children,alert-dialog-a11y,forms,release. No test performs a real npm publication or remote push.API compatibility
No breaking changes.
bindControlled()gained a 4th tuple element and an optional 3rd parameter - 3-element destructuring still works.toNodes()signature unchanged;toChildren()newly exported.open?: booleanprops widened to also accept getters.Switchgainedvalue/required;package.jsongainedengines.node.Known limitations
:nth-childselectors or counting children would see one extra element.RadioGroupItem, which must locate its group first), soFormDataread synchronously after construction is empty.package.jsonlistsLICENSEinfiles, but noLICENSEfile exists in the repo.