Skip to content

refactor(scrollbar): align ScrollbarComponent with CameraComponent architecture#8693

Merged
willeastcott merged 2 commits into
mainfrom
refactor/scrollbar-component-camera-style
May 6, 2026
Merged

refactor(scrollbar): align ScrollbarComponent with CameraComponent architecture#8693
willeastcott merged 2 commits into
mainfrom
refactor/scrollbar-component-camera-style

Conversation

@willeastcott

Copy link
Copy Markdown
Contributor

Summary

Refactor ScrollbarComponent to mirror CameraComponent / LightComponent's architecture, continuing the move away from *ComponentData property bags.

  • ScrollbarComponent now owns _orientation, _value, _handleSize, and _handleEntity directly. Properties no longer round-trip through data[name]; the _setValue helper, the internal set_* event chain, and the _onSet* / _toggleLifecycleListeners plumbing are all gone. The clamp-to-[0, 1] and 1e-5 epsilon semantics for value and handleSize are inlined into the setters.
  • ScrollbarComponentData is reduced to a single enabled field.
  • ScrollbarComponentSystem matches CameraComponentSystem: _schema = ['enabled'], an explicit property list drives initializeComponentData, an explicit cloneComponent is added (the default base-class clone would otherwise lose every non-enabled field), and Component._buildAccessors(ScrollbarComponent.prototype, _schema) handles the enabled accessor.

Public API

No additions, removals, or behaviour changes. The full property surface of ScrollbarComponent is preserved (verified against the regenerated .d.ts): enabled, orientation, value, handleSize, handleEntity, and the static EVENT_SETVALUE / set:value event still consumed by ScrollViewComponent.

Tests

Adds test/framework/components/scrollbar/component.test.mjs, modelled on the LightComponent test suite, covering #addComponent (defaults + round-trip), #value (clamp + set:value firing + epsilon no-op), #handleSize (clamp + epsilon no-op), #orientation (opposite-dimension clear on change), #handleEntity (Entity / GUID-string / null / unsubscribe-on-reassign), #cloneComponent (scalar round-trip + handle remap), and resolveDuplicatedEntityReferenceProperties. The suite was confirmed green against main before the refactor and remains green after it.

Test plan

  • npm run lint passes
  • npm run build:types regenerates .d.ts cleanly; npm run test:types passes
  • npm test — 1688 passing, 0 failing

…chitecture

ScrollbarComponent now owns its `orientation`, `value`, `handleSize`,
and `handleEntity` state in private fields, mirroring the
CameraComponent / LightComponent pattern. The bespoke `_setValue` /
`data[name]` indirection and the internal `set_*` event chain are
removed, ScrollbarComponentData is reduced to `enabled`, and the
ScrollbarComponentSystem is now pure boilerplate driven by
`_schema = ['enabled']`, an explicit `_properties` list, and an
explicit `cloneComponent`. Adds a unit-test suite to guard the
behaviour through the migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
@willeastcott willeastcott merged commit 5fadb06 into main May 6, 2026
8 checks passed
@willeastcott willeastcott deleted the refactor/scrollbar-component-camera-style branch May 6, 2026 19:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors ScrollbarComponent to follow the newer component architecture used by CameraComponent / LightComponent, reducing reliance on *ComponentData property bags while preserving the public API and adding targeted unit tests.

Changes:

  • Move orientation, value, handleSize, and handleEntity storage into ScrollbarComponent instance fields; reduce ScrollbarComponentData to enabled.
  • Update ScrollbarComponentSystem to use _schema = ['enabled'], explicit initialization property ordering, and an explicit cloneComponent.
  • Add a comprehensive ScrollbarComponent unit test suite covering defaults, clamping/epsilon behavior, entity reference handling, cloning, and duplicated-entity remapping.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/framework/components/scrollbar/component.test.mjs Adds new unit coverage for the refactored ScrollbarComponent behaviors.
src/framework/components/scrollbar/system.js Aligns system initialization/cloning/accessors with schema-only enabled plus explicit properties list.
src/framework/components/scrollbar/data.js Reduces component data to enabled only.
src/framework/components/scrollbar/component.js Moves scrollbar state off the data bag into component-owned fields and simplifies setter/event plumbing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 108 to +117
set orientation(arg) {
this._setValue('orientation', arg);
if (this._orientation === arg) {
return;
}

this._orientation = arg;

if (this._handleEntity?.element) {
this._handleEntity.element[this._getOppositeDimension()] = 0;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed - this is a real (pre-existing) bug. ElementDragHelper captures its axis at construction, so flipping orientation at runtime leaves drags constrained to the old axis and can stop value updates from happening. Fixed in 6016d61bf as part of #8694: the orientation setter now rebuilds the drag helper and calls _updateHandlePositionAndSize() when a handle element is already attached. Regression test included.

willeastcott added a commit that referenced this pull request May 6, 2026
ElementDragHelper captures its constraint axis at construction time, so
flipping the scrollbar orientation while a handle element exists left
the helper still constraining drags to the old axis. _onHandleDrag
would then read the wrong component of the position vector and value
updates could stop working until the helper was destroyed and rebuilt
through some other path (e.g. handleEntity reassignment).

Extract the helper-rebuild logic from _onHandleElementGain into a
shared _rebuildDragHelper method, and call it (plus
_updateHandlePositionAndSize) from the orientation setter when there
is an active handle element.

Adds a regression test that flips the orientation post-attach and
verifies the drag helper is rebuilt for the new axis.

Reported by Copilot in #8693.

Co-authored-by: Cursor <cursoragent@cursor.com>
willeastcott added a commit that referenced this pull request May 6, 2026
* refactor(scrollbar): tighten ScrollbarComponent internals

Small follow-up cleanup to #8693:

- Declare _handleDragHelper as a class field (was implicit-via-assignment).
- Tighten the handleEntity setter: resolve the candidate entity first,
  then a single early-return covers both "same Entity by ref" and
  "same GUID by string" cases. Switch || null to ?? null.
- Drop the _onSetHandleAlignment passthrough; bind
  _updateHandlePositionAndSize directly to set:anchor / set:margin /
  set:pivot events on the handle element.
- Use an early return in _updateHandlePositionAndSize.
- _destroyDragHelper now uses optional chaining and nulls the field
  after destroy, so a post-element-loss enabled toggle no longer
  writes to a torn-down helper.
- Inline the _setHandleDraggingEnabled helper into onEnable / onDisable.

No public API changes. The 16 existing scrollbar tests still pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scrollbar): respect component enabled state when building a fresh drag helper

ElementDragHelper defaults to enabled = true in its constructor, so a
helper created in _onHandleElementGain (e.g. when an element is added
to the handle entity after the scrollbar is already disabled) would
start out draggable even though onDisable had already run. Mirror the
component's current enabled state on the new helper to keep the
lifecycle consistent.

Adds a regression test that exercises the late-arriving-element path
on a disabled scrollbar.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scrollbar): rebuild drag helper when orientation changes at runtime

ElementDragHelper captures its constraint axis at construction time, so
flipping the scrollbar orientation while a handle element exists left
the helper still constraining drags to the old axis. _onHandleDrag
would then read the wrong component of the position vector and value
updates could stop working until the helper was destroyed and rebuilt
through some other path (e.g. handleEntity reassignment).

Extract the helper-rebuild logic from _onHandleElementGain into a
shared _rebuildDragHelper method, and call it (plus
_updateHandlePositionAndSize) from the orientation setter when there
is an active handle element.

Adds a regression test that flips the orientation post-attach and
verifies the drag helper is rebuilt for the new axis.

Reported by Copilot in #8693.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(scrollbar): correct handleEntity JSDoc to reference ElementComponent

The handleEntity setter's JSDoc claimed the handle "must have a
ScrollbarComponent", but the code relies on the handle's element
(ElementDragHelper is constructed from handleEntity.element). Update
the doc to require an ElementComponent, with useInput: true for the
handle to be draggable. Reported by Copilot in #8694.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
willeastcott added a commit that referenced this pull request May 26, 2026
…architecture (#8777)

* refactor(scrollview): align ScrollViewComponent with CameraComponent architecture

Apply the same refactor pattern recently adopted by ScrollbarComponent
(#8693): the component now owns its state directly via private fields,
setters do their work inline, the data-bag round-trip and set_<name>
event chain are gone, and the system declares `_schema = ['enabled']`
with an explicit `_properties` list and `cloneComponent`.

`mouseWheelSensitivity` previously relied on the schema's `type: 'vec2'`
auto-conversion (clones Vec2 inputs, builds a Vec2 from `[x, y]` arrays);
that work is moved into the setter itself so JSON-loaded scenes shipping
`mouseWheelSensitivity: [1, 1]` and callers passing shared Vec2 instances
continue to work.

Add `test/framework/components/scroll-view/component.test.mjs` covering
defaults, the round-trip via `addComponent`, the Vec2/array normalization
on `mouseWheelSensitivity`, side effects on `horizontal`/`vertical`,
`set:scroll`, all four entity setters (Entity / GUID / null /
unsubscribe-on-reassign), `cloneComponent`, and
`resolveDuplicatedEntityReferenceProperties`.

No public API change: properties, behavior, and the `set:scroll` event
are preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scrollview): broaden mouseWheelSensitivity input + tear down refs on remove

Two follow-ups from Copilot's review on #8777:

- `mouseWheelSensitivity` setter was narrower than the old schema-driven
  `convertValue('vec2')`: it only normalized `Array.isArray(arg)`, so a
  Float32Array (or any other indexable input) would slip through to the
  passthrough branch and break later code that reads `.x`/`.y`. Match the
  old contract: nullish passes through, Vec2 is cloned, anything else is
  treated as indexable.

- `onRemove` only detached the entity's own `element:add` and element
  listeners. Listeners registered on referenced viewport / content /
  scrollbar entities, plus the pending `_evtElementRemove` once-handle
  on the element, stayed live - keeping the removed component reachable
  from those entities. Set every entity ref to null (which routes through
  the existing setter -> unsubscribe path) and `.off()` the pending
  once-handle. This is also a pre-existing leak, not a regression from
  the refactor.

Adds tests for both: a Float32Array round-trip on `mouseWheelSensitivity`,
and a `removeComponent` test asserting that referenced entities no longer
have any of the component's listeners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scrollview): skip explicit undefined in initializeComponentData

Copilot review on #8777 caught a behavior regression: the old
initializeComponentData normalized dragThreshold / useMouseWheel /
mouseWheelSensitivity when they were `=== undefined`, so callers shipping
`{ dragThreshold: undefined }` still ended up with the default 10. The
refactored loop guarded with `hasOwnProperty`, which let explicit
undefined values clobber the class-field defaults - turning
`useMouseWheel` falsy (disables wheel scrolling) and `mouseWheelSensitivity`
into undefined (`_onMouseWheel` then throws on `.x`/`.y`).

Tighten the guard to `data[property] !== undefined` so the class-field
defaults survive explicit-undefined input, exactly matching the legacy
behavior.

Adds a test that addComponent with `{ dragThreshold: undefined, useMouseWheel:
undefined, mouseWheelSensitivity: undefined }` lands on the documented
defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ui UI related issue enhancement Request for a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants