Skip to content

Commit 06bb4b6

Browse files
xerialclaude
andcommitted
fix: Use join instead of zip for combined reactive streams
zip only emits when both streams update, but scroll/resize often changes only one axis. join emits when either stream changes, capturing all events. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7ec0e9f commit 06bb4b6

3 files changed

Lines changed: 192 additions & 4 deletions

File tree

plans/wobbly-knitting-diffie.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# uni-dom Window Events Implementation Plan
2+
3+
## Overview
4+
5+
Add reactive window event bindings for scroll tracking, visibility state, and window dimensions to uni-dom. Follows the established patterns from MediaQuery and NetworkStatus.
6+
7+
## Components
8+
9+
### 1. WindowScroll - Scroll Position Tracking
10+
11+
Reactive scroll position with built-in throttling for performance.
12+
13+
**API:**
14+
```scala
15+
object WindowScroll:
16+
def x: Double // Current horizontal scroll
17+
def y: Double // Current vertical scroll
18+
def scrollX: Rx[Double] // Reactive horizontal position
19+
def scrollY: Rx[Double] // Reactive vertical position
20+
def scroll: Rx[(Double, Double)] // Combined (x, y) stream
21+
def stop(): Unit // Cleanup
22+
```
23+
24+
**Usage:**
25+
```scala
26+
// Show "back to top" button when scrolled
27+
div(
28+
WindowScroll.scrollY.map { y =>
29+
if y > 500 then button("Back to Top") else DomNode.empty
30+
}
31+
)
32+
```
33+
34+
**Implementation notes:**
35+
- Uses 16ms throttle (~60fps) for performance
36+
- Leading + trailing edge throttling ensures final position is captured
37+
- Private inner class with lazy val singleton
38+
39+
### 2. WindowVisibility - Document Visibility State
40+
41+
Track whether the page is visible or hidden (tab switching, minimizing).
42+
43+
**API:**
44+
```scala
45+
object WindowVisibility:
46+
def isVisible: Boolean // Current visibility
47+
def state: String // "visible", "hidden", etc.
48+
def visible: Rx[Boolean] // Reactive visibility
49+
def hidden: Rx[Boolean] // Inverse of visible
50+
def visibilityState: Rx[String] // Reactive state string
51+
def stop(): Unit // Cleanup
52+
```
53+
54+
**Usage:**
55+
```scala
56+
// Pause video when tab is hidden
57+
WindowVisibility.visible.run { visible =>
58+
if visible then video.play() else video.pause()
59+
}
60+
```
61+
62+
### 3. WindowDimensions - Window Size Tracking
63+
64+
Reactive window dimensions with resize tracking.
65+
66+
**API:**
67+
```scala
68+
object WindowDimensions:
69+
def innerWidth: Int // Viewport width
70+
def innerHeight: Int // Viewport height
71+
def outerWidth: Int // Full window width
72+
def outerHeight: Int // Full window height
73+
def rxInnerWidth: Rx[Int] // Reactive viewport width
74+
def rxInnerHeight: Rx[Int] // Reactive viewport height
75+
def rxOuterWidth: Rx[Int] // Reactive window width
76+
def rxOuterHeight: Rx[Int] // Reactive window height
77+
def dimensions: Rx[(Int, Int)] // Combined (width, height)
78+
def stop(): Unit // Cleanup
79+
```
80+
81+
**Usage:**
82+
```scala
83+
// Responsive layout without MediaQuery
84+
div(
85+
WindowDimensions.rxInnerWidth.map { width =>
86+
if width < 768 then "mobile-layout" else "desktop-layout"
87+
}
88+
)
89+
```
90+
91+
## Files to Create
92+
93+
| File | Description |
94+
|------|-------------|
95+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowScroll.scala` | Scroll position tracking |
96+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowVisibility.scala` | Document visibility state |
97+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowDimensions.scala` | Window dimensions tracking |
98+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowScrollTest.scala` | Scroll tests |
99+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowVisibilityTest.scala` | Visibility tests |
100+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowDimensionsTest.scala` | Dimensions tests |
101+
102+
## Files to Modify
103+
104+
| File | Changes |
105+
|------|---------|
106+
| `uni/.js/src/main/scala/wvlet/uni/dom/all.scala` | Add exports for WindowScroll, WindowVisibility, WindowDimensions |
107+
108+
## Implementation Pattern
109+
110+
Follow the NetworkStatus pattern:
111+
112+
```scala
113+
object WindowXxx:
114+
private class XxxVar extends Cancelable:
115+
private val underlying = Rx.variable(initialValue)
116+
private val listener: js.Function1[dom.Event, Unit] = (_: dom.Event) =>
117+
underlying := newValue
118+
119+
dom.window.addEventListener("event", listener)
120+
121+
def get: T = underlying.get
122+
def rx: Rx[T] = underlying
123+
124+
override def cancel: Unit =
125+
dom.window.removeEventListener("event", listener)
126+
127+
private lazy val instance: XxxVar = XxxVar()
128+
129+
def value: T = instance.get
130+
def rxValue: Rx[T] = instance.rx
131+
def stop(): Unit = instance.cancel
132+
```
133+
134+
## Scroll Throttling Strategy
135+
136+
```scala
137+
private var lastUpdateTime: Double = 0
138+
private var scheduledUpdate: js.UndefOr[Int] = js.undefined
139+
140+
private val listener: js.Function1[dom.Event, Unit] = (_: dom.Event) =>
141+
val now = js.Date.now()
142+
if now - lastUpdateTime >= throttleMs then
143+
// Leading edge: update immediately
144+
lastUpdateTime = now
145+
updateValues()
146+
else if scheduledUpdate.isEmpty then
147+
// Trailing edge: schedule final update
148+
scheduledUpdate = dom.window.setTimeout(
149+
() => {
150+
scheduledUpdate = js.undefined
151+
lastUpdateTime = js.Date.now()
152+
updateValues()
153+
},
154+
throttleMs - (now - lastUpdateTime).toInt
155+
)
156+
```
157+
158+
## Implementation Order
159+
160+
1. WindowVisibility (simplest, follows NetworkStatus exactly)
161+
2. WindowDimensions (similar, just more RxVars)
162+
3. WindowScroll (most complex due to throttling)
163+
4. Update all.scala exports
164+
5. Create tests
165+
166+
## Verification
167+
168+
```bash
169+
# In worktree
170+
cd /Users/leo/work/uni/.worktree/feature-window-events
171+
172+
# Compile
173+
./sbt compile
174+
175+
# Run tests
176+
./sbt "domTestJS/test"
177+
178+
# Format
179+
./sbt scalafmtAll
180+
```
181+
182+
## References
183+
184+
Pattern files to follow:
185+
- `uni/.js/src/main/scala/wvlet/uni/dom/NetworkStatus.scala` - Primary pattern
186+
- `uni/.js/src/main/scala/wvlet/uni/dom/MediaQuery.scala` - Secondary pattern
187+
- `uni-dom-test/src/test/scala/wvlet/uni/dom/NetworkStatusTest.scala` - Test pattern

uni/.js/src/main/scala/wvlet/uni/dom/WindowDimensions.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@ object WindowDimensions:
113113
def rxOuterHeight: Rx[Int] = instance.rxOuterHeight
114114

115115
/**
116-
* Reactive stream of combined viewport dimensions (width, height).
116+
* Reactive stream of combined viewport dimensions (width, height). Emits when either dimension
117+
* changes.
117118
*/
118-
def dimensions: Rx[(Int, Int)] = instance.rxInnerWidth.zip(instance.rxInnerHeight)
119+
def dimensions: Rx[(Int, Int)] = instance.rxInnerWidth.join(instance.rxInnerHeight)
119120

120121
/**
121122
* Stop listening to resize events. Call this when the application is shutting down to clean up

uni/.js/src/main/scala/wvlet/uni/dom/WindowScroll.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ object WindowScroll:
113113
def scrollY: Rx[Double] = instance.rxY
114114

115115
/**
116-
* Reactive stream of combined scroll position (x, y).
116+
* Reactive stream of combined scroll position (x, y). Emits when either x or y changes.
117117
*/
118-
def scroll: Rx[(Double, Double)] = instance.rxX.zip(instance.rxY)
118+
def scroll: Rx[(Double, Double)] = instance.rxX.join(instance.rxY)
119119

120120
/**
121121
* Stop listening to scroll events. Call this when the application is shutting down to clean up

0 commit comments

Comments
 (0)