@dimina-kit/view-anchor 0.1.0-dev.20260701083540 → 0.1.0-dev.20260702145840
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,
|
|
1
|
+
{"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CAuPvB"}
|
package/dist/view-anchor.js
CHANGED
|
@@ -182,6 +182,8 @@ export function createPlacementAnchor(target, opts) {
|
|
|
182
182
|
let rafId = null;
|
|
183
183
|
let steadyFrames = 0;
|
|
184
184
|
const STEADY_CLOSE_FRAMES = 2;
|
|
185
|
+
// Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
|
|
186
|
+
const MAX_HIDDEN_FOLLOW_FRAMES = 30;
|
|
185
187
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
186
188
|
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
187
189
|
// A held pointer means the drag may still resume after a static pause, so a
|
|
@@ -216,11 +218,16 @@ export function createPlacementAnchor(target, opts) {
|
|
|
216
218
|
lastPublished = p;
|
|
217
219
|
publish(p);
|
|
218
220
|
};
|
|
221
|
+
// Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
|
|
222
|
+
// bounded follow run elapsed, else keep following (false); never publishes.
|
|
223
|
+
const shouldCloseOnHiddenPoll = () => {
|
|
224
|
+
if (lastPublished?.visible === false)
|
|
225
|
+
return true;
|
|
226
|
+
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
|
|
227
|
+
};
|
|
219
228
|
// One sentinel frame: measure, publish-in-frame if changed, else count toward
|
|
220
|
-
// the steady-close threshold.
|
|
221
|
-
//
|
|
222
|
-
// re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
|
|
223
|
-
// inert.
|
|
229
|
+
// the steady-close threshold. Reads `disposed`/`visible` live so a frame
|
|
230
|
+
// outliving teardown is inert.
|
|
224
231
|
const sentinelFrame = () => {
|
|
225
232
|
rafId = null;
|
|
226
233
|
if (disposed || !visible) {
|
|
@@ -234,6 +241,16 @@ export function createPlacementAnchor(target, opts) {
|
|
|
234
241
|
return; // duration elapsed → close (no re-arm)
|
|
235
242
|
}
|
|
236
243
|
const p = computePlacement();
|
|
244
|
+
// The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
|
|
245
|
+
// hidden poll is a relayout transient to follow until restore (the fix).
|
|
246
|
+
if (!p.visible) {
|
|
247
|
+
if (shouldCloseOnHiddenPoll()) {
|
|
248
|
+
sentinelDeadline = null;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
rafId = requestAnimationFrame(sentinelFrame);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
237
254
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
238
255
|
steadyFrames++;
|
|
239
256
|
// Steady-close only fires once the pointer is RELEASED: while a
|
package/docs/anchor-3d.html
CHANGED
|
@@ -378,6 +378,7 @@ var __viewAnchorCore = (() => {
|
|
|
378
378
|
let rafId = null;
|
|
379
379
|
let steadyFrames = 0;
|
|
380
380
|
const STEADY_CLOSE_FRAMES = 2;
|
|
381
|
+
const MAX_HIDDEN_FOLLOW_FRAMES = 30;
|
|
381
382
|
let pointerHeld = false;
|
|
382
383
|
let sentinelDeadline = null;
|
|
383
384
|
const computePlacement = () => {
|
|
@@ -394,6 +395,10 @@ var __viewAnchorCore = (() => {
|
|
|
394
395
|
lastPublished = p;
|
|
395
396
|
publish(p);
|
|
396
397
|
};
|
|
398
|
+
const shouldCloseOnHiddenPoll = () => {
|
|
399
|
+
if (lastPublished?.visible === false) return true;
|
|
400
|
+
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
|
|
401
|
+
};
|
|
397
402
|
const sentinelFrame = () => {
|
|
398
403
|
rafId = null;
|
|
399
404
|
if (disposed || !visible) {
|
|
@@ -405,6 +410,14 @@ var __viewAnchorCore = (() => {
|
|
|
405
410
|
return;
|
|
406
411
|
}
|
|
407
412
|
const p = computePlacement();
|
|
413
|
+
if (!p.visible) {
|
|
414
|
+
if (shouldCloseOnHiddenPoll()) {
|
|
415
|
+
sentinelDeadline = null;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
rafId = requestAnimationFrame(sentinelFrame);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
408
421
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
409
422
|
steadyFrames++;
|
|
410
423
|
if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/view-anchor",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.20260702145840",
|
|
4
4
|
"description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"react-dom": "^18.3.1",
|
|
61
61
|
"typescript": "5.9.2",
|
|
62
62
|
"vitest": "^4.1.4",
|
|
63
|
-
"@dimina-kit/
|
|
64
|
-
"@dimina-kit/
|
|
63
|
+
"@dimina-kit/typescript-config": "0.1.0",
|
|
64
|
+
"@dimina-kit/eslint-config": "0.1.0"
|
|
65
65
|
},
|
|
66
66
|
"publishConfig": {
|
|
67
67
|
"access": "public"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { createPlacementAnchor } from './view-anchor.js'
|
|
3
|
+
import type { Placement } from './types.js'
|
|
4
|
+
|
|
5
|
+
// Locks the correct behaviour of the followGeometry sentinel when a
|
|
6
|
+
// guardDisplayNone anchor's slot momentarily measures 0×0 during a
|
|
7
|
+
// relayout (e.g. device/orientation switch).
|
|
8
|
+
//
|
|
9
|
+
// On a slot transition the dock reflows; for one or more animation frames
|
|
10
|
+
// the slot rect collapses to 0×0 before being restored to its real non-zero
|
|
11
|
+
// size. ResizeObserver coalesces the net B→0→B change away and never fires,
|
|
12
|
+
// so the ONLY observer of the transient zero is the RAF sentinel.
|
|
13
|
+
//
|
|
14
|
+
// Correct behaviour: the sentinel MUST NOT publish {visible:false} from a
|
|
15
|
+
// transient zero poll. Detaching is owned by ResizeObserver / IntersectionObserver,
|
|
16
|
+
// not the sentinel. After a transient-zero-then-restore sequence driven
|
|
17
|
+
// purely through the sentinel (no RO fire), the final placement must be
|
|
18
|
+
// {visible:true, bounds:<restored rect>}.
|
|
19
|
+
|
|
20
|
+
// ── Controllable fake requestAnimationFrame ──────────────────────────────
|
|
21
|
+
class FakeRaf {
|
|
22
|
+
private cbs = new Map<number, FrameRequestCallback>()
|
|
23
|
+
private nextId = 1
|
|
24
|
+
request = vi.fn((cb: FrameRequestCallback): number => {
|
|
25
|
+
const id = this.nextId++
|
|
26
|
+
this.cbs.set(id, cb)
|
|
27
|
+
return id
|
|
28
|
+
})
|
|
29
|
+
cancel = vi.fn((id: number): void => {
|
|
30
|
+
this.cbs.delete(id)
|
|
31
|
+
})
|
|
32
|
+
/** Drain exactly the callbacks pending at call-time; a re-request lands in
|
|
33
|
+
* the next flush, so flushFrame() advances exactly one logical frame. */
|
|
34
|
+
flushFrame(ts = 0): void {
|
|
35
|
+
const pending = [...this.cbs.entries()]
|
|
36
|
+
this.cbs.clear()
|
|
37
|
+
for (const [, cb] of pending) cb(ts)
|
|
38
|
+
}
|
|
39
|
+
get pending(): number {
|
|
40
|
+
return this.cbs.size
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Minimal ResizeObserver stub ──────────────────────────────────────────
|
|
45
|
+
class FakeResizeObserver {
|
|
46
|
+
static instances: FakeResizeObserver[] = []
|
|
47
|
+
observed: Element[] = []
|
|
48
|
+
disconnected = false
|
|
49
|
+
constructor(public cb: ResizeObserverCallback) {
|
|
50
|
+
FakeResizeObserver.instances.push(this)
|
|
51
|
+
}
|
|
52
|
+
observe(el: Element): void {
|
|
53
|
+
this.observed.push(el)
|
|
54
|
+
}
|
|
55
|
+
unobserve(): void {
|
|
56
|
+
/* unused */
|
|
57
|
+
}
|
|
58
|
+
disconnect(): void {
|
|
59
|
+
this.disconnected = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let raf: FakeRaf
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
FakeResizeObserver.instances = []
|
|
67
|
+
raf = new FakeRaf()
|
|
68
|
+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
'requestAnimationFrame',
|
|
71
|
+
raf.request as unknown as typeof window.requestAnimationFrame,
|
|
72
|
+
)
|
|
73
|
+
vi.stubGlobal(
|
|
74
|
+
'cancelAnimationFrame',
|
|
75
|
+
raf.cancel as unknown as typeof window.cancelAnimationFrame,
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.restoreAllMocks()
|
|
81
|
+
vi.unstubAllGlobals()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
function buildElement(rect: { x: number; y: number; w: number; h: number }): {
|
|
85
|
+
el: HTMLElement
|
|
86
|
+
setRect: (next: { x: number; y: number; w: number; h: number }) => void
|
|
87
|
+
} {
|
|
88
|
+
const el = document.createElement('div')
|
|
89
|
+
let current = rect
|
|
90
|
+
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
|
|
91
|
+
() =>
|
|
92
|
+
({
|
|
93
|
+
x: current.x,
|
|
94
|
+
y: current.y,
|
|
95
|
+
left: current.x,
|
|
96
|
+
top: current.y,
|
|
97
|
+
right: current.x + current.w,
|
|
98
|
+
bottom: current.y + current.h,
|
|
99
|
+
width: current.w,
|
|
100
|
+
height: current.h,
|
|
101
|
+
toJSON: () => ({}),
|
|
102
|
+
}) as DOMRect,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
el,
|
|
106
|
+
setRect(next) {
|
|
107
|
+
current = next
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Options cast: followGeometry / guardDisplayNone not on the exported TS
|
|
113
|
+
// types yet — cast through so tests compile without touching the types file.
|
|
114
|
+
type PlacementOpts = Parameters<typeof createPlacementAnchor>[1] & {
|
|
115
|
+
followGeometry?: boolean
|
|
116
|
+
guardDisplayNone?: boolean
|
|
117
|
+
}
|
|
118
|
+
const mk = (
|
|
119
|
+
el: HTMLElement,
|
|
120
|
+
o: {
|
|
121
|
+
visible: boolean
|
|
122
|
+
publish: (p: Placement) => void
|
|
123
|
+
followGeometry?: boolean
|
|
124
|
+
guardDisplayNone?: boolean
|
|
125
|
+
},
|
|
126
|
+
): ReturnType<typeof createPlacementAnchor> =>
|
|
127
|
+
createPlacementAnchor(el, o as PlacementOpts)
|
|
128
|
+
|
|
129
|
+
describe('createPlacementAnchor — followGeometry sentinel must not detach on a transient zero-area poll', () => {
|
|
130
|
+
// Core bug: slot goes 0×0 transiently during a dock relayout; RO never
|
|
131
|
+
// fires (net change is zero); the sentinel is the only observer. The sentinel
|
|
132
|
+
// must NOT publish {visible:false} and must not close on the zero frames so
|
|
133
|
+
// it can follow the restored rect.
|
|
134
|
+
it('transient 0×0 poll → restore: final placement is visible:true (not a detach)', () => {
|
|
135
|
+
const publishes: Placement[] = []
|
|
136
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
|
|
137
|
+
|
|
138
|
+
mk(el, {
|
|
139
|
+
visible: true,
|
|
140
|
+
guardDisplayNone: true,
|
|
141
|
+
followGeometry: true,
|
|
142
|
+
publish: (p) => publishes.push(p),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Synchronous initial publish on creation.
|
|
146
|
+
expect(publishes).toHaveLength(1)
|
|
147
|
+
expect(publishes[0]).toEqual({
|
|
148
|
+
visible: true,
|
|
149
|
+
bounds: { x: 0, y: 0, width: 400, height: 800 },
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Open the sentinel (imperative pulse, no duration → steady-close only,
|
|
153
|
+
// no time-stubbing needed).
|
|
154
|
+
const anchor = mk(el, {
|
|
155
|
+
visible: true,
|
|
156
|
+
guardDisplayNone: true,
|
|
157
|
+
followGeometry: true,
|
|
158
|
+
publish: (p) => publishes.push(p),
|
|
159
|
+
})
|
|
160
|
+
// Reset to track only the sentinel's publishes from here on.
|
|
161
|
+
publishes.length = 0
|
|
162
|
+
anchor.pulse()
|
|
163
|
+
|
|
164
|
+
// Simulate the slot collapsing to 0×0 (transient relayout).
|
|
165
|
+
// Do NOT fire ResizeObserver (it coalesces the net B→0→B away).
|
|
166
|
+
setRect({ x: 0, y: 0, w: 0, h: 0 })
|
|
167
|
+
|
|
168
|
+
// Flush enough frames to exceed the steady-close threshold (STEADY_CLOSE_FRAMES=2)
|
|
169
|
+
// so the buggy sentinel closes on the zero.
|
|
170
|
+
raf.flushFrame() // frame 1 at zero
|
|
171
|
+
raf.flushFrame() // frame 2 at zero
|
|
172
|
+
raf.flushFrame() // frame 3 at zero — buggy path closes here
|
|
173
|
+
|
|
174
|
+
// Now restore the slot to its real (moved) non-zero rect.
|
|
175
|
+
setRect({ x: 120, y: 0, w: 400, h: 800 })
|
|
176
|
+
|
|
177
|
+
// Drive additional frames; with the fix the sentinel is still open and
|
|
178
|
+
// picks up the restored rect. With the bug the sentinel already closed —
|
|
179
|
+
// these flushes are no-ops but that's fine: the assertion is on the LAST
|
|
180
|
+
// published placement regardless.
|
|
181
|
+
raf.flushFrame()
|
|
182
|
+
raf.flushFrame()
|
|
183
|
+
|
|
184
|
+
// The sentinel must NEVER have emitted {visible:false}.
|
|
185
|
+
const detaches = publishes.filter((p) => p.visible === false)
|
|
186
|
+
expect(detaches).toHaveLength(0)
|
|
187
|
+
|
|
188
|
+
// And the view must not be left detached: the last placement is visible:true.
|
|
189
|
+
expect(publishes.length).toBeGreaterThan(0)
|
|
190
|
+
const last = publishes[publishes.length - 1]
|
|
191
|
+
expect(last).toMatchObject({ visible: true })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// No-regression: the sentinel must still follow a normal visible move
|
|
195
|
+
// (pulse → setRect to moved non-zero rect → flushFrame → published).
|
|
196
|
+
// Guards against a "fix" that simply disables sentinel publishing.
|
|
197
|
+
it('sentinel follows a normal visible-rect move after pulse()', () => {
|
|
198
|
+
const publishes: Placement[] = []
|
|
199
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
|
|
200
|
+
|
|
201
|
+
const anchor = mk(el, {
|
|
202
|
+
visible: true,
|
|
203
|
+
guardDisplayNone: true,
|
|
204
|
+
followGeometry: true,
|
|
205
|
+
publish: (p) => publishes.push(p),
|
|
206
|
+
})
|
|
207
|
+
publishes.length = 0 // clear the initial synchronous publish
|
|
208
|
+
|
|
209
|
+
anchor.pulse()
|
|
210
|
+
|
|
211
|
+
// Move the slot to a new non-zero position (e.g. after a panel resize).
|
|
212
|
+
setRect({ x: 200, y: 50, w: 400, h: 800 })
|
|
213
|
+
raf.flushFrame()
|
|
214
|
+
|
|
215
|
+
expect(publishes).toContainEqual({
|
|
216
|
+
visible: true,
|
|
217
|
+
bounds: { x: 200, y: 50, width: 400, height: 800 },
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// Defensive bound: while the sentinel keeps FOLLOWING a hidden poll (a
|
|
222
|
+
// relayout transient where last-published is still visible and RO/IO never
|
|
223
|
+
// fire), it must not spin forever. After a finite run of consecutive hidden
|
|
224
|
+
// polls it stops scheduling new frames (window closes) — and it must STILL
|
|
225
|
+
// never publish a {visible:false} detach during that bounded run.
|
|
226
|
+
it('sentinel stops polling after a bounded run of hidden frames — no infinite rAF, no detach', () => {
|
|
227
|
+
const publishes: Placement[] = []
|
|
228
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
|
|
229
|
+
|
|
230
|
+
const anchor = mk(el, {
|
|
231
|
+
visible: true,
|
|
232
|
+
guardDisplayNone: true,
|
|
233
|
+
followGeometry: true,
|
|
234
|
+
publish: (p) => publishes.push(p),
|
|
235
|
+
})
|
|
236
|
+
publishes.length = 0 // clear the initial synchronous publish
|
|
237
|
+
|
|
238
|
+
anchor.pulse()
|
|
239
|
+
|
|
240
|
+
// Slot collapses to 0×0 and stays hidden. RO/IO never fire.
|
|
241
|
+
setRect({ x: 0, y: 0, w: 0, h: 0 })
|
|
242
|
+
|
|
243
|
+
// Drive frames until the sentinel closes on its own (raf.pending === 0),
|
|
244
|
+
// with a hard safety break so a genuinely unbounded spin still terminates
|
|
245
|
+
// the test instead of hanging.
|
|
246
|
+
let n = 0
|
|
247
|
+
while (raf.pending) {
|
|
248
|
+
raf.flushFrame()
|
|
249
|
+
if (++n > 200) break
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The loop terminated because the sentinel CLOSED (pending → 0), not
|
|
253
|
+
// because the safety break fired, and it converged quickly.
|
|
254
|
+
expect(raf.pending).toBe(0)
|
|
255
|
+
expect(n).toBeLessThan(100)
|
|
256
|
+
|
|
257
|
+
// The bounded close must still never emit a detach.
|
|
258
|
+
const detaches = publishes.filter((p) => p.visible === false)
|
|
259
|
+
expect(detaches).toHaveLength(0)
|
|
260
|
+
})
|
|
261
|
+
})
|
package/src/view-anchor.ts
CHANGED
|
@@ -271,6 +271,8 @@ export function createPlacementAnchor(
|
|
|
271
271
|
let rafId: number | null = null
|
|
272
272
|
let steadyFrames = 0
|
|
273
273
|
const STEADY_CLOSE_FRAMES = 2
|
|
274
|
+
// Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
|
|
275
|
+
const MAX_HIDDEN_FOLLOW_FRAMES = 30
|
|
274
276
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
275
277
|
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
276
278
|
// A held pointer means the drag may still resume after a static pause, so a
|
|
@@ -308,11 +310,16 @@ export function createPlacementAnchor(
|
|
|
308
310
|
publish(p)
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
// Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
|
|
314
|
+
// bounded follow run elapsed, else keep following (false); never publishes.
|
|
315
|
+
const shouldCloseOnHiddenPoll = (): boolean => {
|
|
316
|
+
if (lastPublished?.visible === false) return true
|
|
317
|
+
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
|
|
318
|
+
}
|
|
319
|
+
|
|
311
320
|
// One sentinel frame: measure, publish-in-frame if changed, else count toward
|
|
312
|
-
// the steady-close threshold.
|
|
313
|
-
//
|
|
314
|
-
// re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
|
|
315
|
-
// inert.
|
|
321
|
+
// the steady-close threshold. Reads `disposed`/`visible` live so a frame
|
|
322
|
+
// outliving teardown is inert.
|
|
316
323
|
const sentinelFrame = (): void => {
|
|
317
324
|
rafId = null
|
|
318
325
|
if (disposed || !visible) {
|
|
@@ -326,6 +333,13 @@ export function createPlacementAnchor(
|
|
|
326
333
|
return // duration elapsed → close (no re-arm)
|
|
327
334
|
}
|
|
328
335
|
const p = computePlacement()
|
|
336
|
+
// The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
|
|
337
|
+
// hidden poll is a relayout transient to follow until restore (the fix).
|
|
338
|
+
if (!p.visible) {
|
|
339
|
+
if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
|
|
340
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
329
343
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
330
344
|
steadyFrames++
|
|
331
345
|
// Steady-close only fires once the pointer is RELEASED: while a
|