Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .sync/log/59440671dd20196ca3a7cc613dc13438251e706d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Port: fix(useScrollspy): unobserve previous headings on update (#6700)

**Upstream:** `59440671dd20196ca3a7cc613dc13438251e706d` (nuxt/ui)
**Decision:** port — direct 1:1

## Upstream change
Two fixes to `useScrollspy`:
1. **Batched callback** — `observerCallback` collects all entry changes into a
`Set` and writes `visibleHeadings` once (`if (changed)`) instead of once per
entry, so the watcher runs a single time per IntersectionObserver callback.
2. **Observer cleanup** — `updateHeadings` now `disconnect()`s the observer and
resets `visibleHeadings` before re-observing, so repeated calls (e.g. page
navigation) don't accumulate stale observed targets.
Adds a full `useScrollspy.spec.ts` with a mock IntersectionObserver.

## b24ui port
b24ui's `src/runtime/composables/useScrollspy.ts` matched upstream exactly.
Applied both function rewrites verbatim. Created
`test/composables/useScrollspy.spec.ts` verbatim (composable-only; import paths
identical in b24ui).

## Tests
No snapshots. New spec adds 7 tests × (nuxt/vue) → +2 test files (229→231),
suite 5355 passed / 6 skipped (was 5341). Targeted run: 14 passed.

## Verify (CI=true)
`dev:prepare` · `lint` · `typecheck` · `test` · `build` — all green.
10 changes: 8 additions & 2 deletions .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"upstream": "nuxt/ui",
"branch": "v4",
"sync_enabled": false,
"cursor": "000aba433df85a8b28dc16127a587fc18822b32f",
"cursor": "59440671dd20196ca3a7cc613dc13438251e706d",
"_cursor_note": "cursor = last upstream commit ported into b24ui (oldest-first, manual cadence). sync_enabled stays false until Phase 2 (porter workflow #67 + CLAUDE_CODE_OAUTH_TOKEN) is wired and trusted. `processed` is maintained per port from now on (backfilled #68-#72 on 2026-06-09).",
"stats": {
"queue_depth": 0,
Expand Down Expand Up @@ -965,10 +965,16 @@
"summary": "fix(defineShortcuts): add missing arrowdown to shiftable keys (#6702) — shiftableKeys had arrowright twice, omitted arrowdown, so shift_arrowdown never fired. b24ui composables/defineShortcuts.ts:41 had identical bug; fixed verbatim. Ported 2 tests (shift_arrowdown fires, bare arrowdown doesn't with Shift). No snapshots. Tests 5313."
},
"000aba433df85a8b28dc16127a587fc18822b32f": {
"pr": 260,
"b24ui_sha": "0651f57c0eb12ea08b786fa55b6a3305bdb2f6d0",
"decision": "port",
"summary": "fix(useFileUpload): keep dropzone type filter reactive to accept (#6699) — useDropZone got dataTypes.value (static snapshot); pass computed ref dataTypes instead. parseAcceptToDataTypes returns [] not undefined for unrestricted; dataTypes typed readonly string[]. b24ui composable matched, applied verbatim. Created test/composables/useFileUpload.spec.ts verbatim (composable-only). +2 test files (229), tests 5341."
},
"59440671dd20196ca3a7cc613dc13438251e706d": {
"pr": null,
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "fix(useFileUpload): keep dropzone type filter reactive to accept (#6699) — useDropZone got dataTypes.value (static snapshot); pass computed ref dataTypes instead. parseAcceptToDataTypes returns [] not undefined for unrestricted; dataTypes typed readonly string[]. b24ui composable matched, applied verbatim. Created test/composables/useFileUpload.spec.ts verbatim (composable-only). +2 test files (229), tests 5341."
"summary": "fix(useScrollspy): unobserve previous headings on update (#6700) — observerCallback batches entry changes into a Set + single write (watcher runs once per callback); updateHeadings disconnect()s observer and resets visibleHeadings before re-observing so navigations don't accumulate stale targets. b24ui composable matched, applied verbatim. Created test/composables/useScrollspy.spec.ts verbatim (mock IntersectionObserver). +2 test files (231), tests 5355."
}
}
}
37 changes: 25 additions & 12 deletions src/runtime/composables/useScrollspy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,41 @@ export function useScrollspy() {
const activeHeadings = ref<string[]>([])

function observerCallback(entries: IntersectionObserverEntry[]) {
entries.forEach((entry) => {
// Batched into a single write so the watcher runs once per callback, not once per entry.
const headings = new Set(visibleHeadings.value)
let changed = false

for (const entry of entries) {
const id = entry.target.id
if (!id) {
return
continue
}

if (entry.isIntersecting) {
visibleHeadings.value = [...visibleHeadings.value, id]
} else {
visibleHeadings.value = visibleHeadings.value.filter(h => h !== id)
if (!headings.has(id)) {
headings.add(id)
changed = true
}
} else if (headings.delete(id)) {
changed = true
}
})
}

if (changed) {
visibleHeadings.value = [...headings]
}
}

function updateHeadings(headings: Element[]) {
headings.forEach((heading) => {
if (!observer.value) {
return
}
if (!observer.value) {
return
}

// Drop previously observed targets so repeated calls (e.g. on page navigation) don't accumulate.
observer.value.disconnect()
visibleHeadings.value = []

observer.value.observe(heading)
})
headings.forEach(heading => observer.value!.observe(heading))
}

watch(visibleHeadings, (val, oldVal) => {
Expand Down
157 changes: 157 additions & 0 deletions test/composables/useScrollspy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { useScrollspy } from '../../src/runtime/composables/useScrollspy'

// Capturing IntersectionObserver: records observed elements and lets the test
// drive intersection callbacks synchronously.
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = []
callback: IntersectionObserverCallback
observed: Element[] = []

constructor(callback: IntersectionObserverCallback) {
this.callback = callback
MockIntersectionObserver.instances.push(this)
}

observe(el: Element) {
this.observed.push(el)
}

unobserve() {}

disconnect() {
this.observed = []
}

trigger(entries: Array<{ target: Element, isIntersecting: boolean }>) {
this.callback(entries as unknown as IntersectionObserverEntry[], this as unknown as IntersectionObserver)
}
}

function heading(id: string): HTMLElement {
const el = document.createElement('div')
el.id = id
return el
}

describe('useScrollspy', () => {
let wrapper: Awaited<ReturnType<typeof mountSuspended>>

beforeEach(() => {
MockIntersectionObserver.instances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})

afterEach(() => {
wrapper?.unmount()
vi.unstubAllGlobals()
})

async function mountSpy() {
let api!: ReturnType<typeof useScrollspy>
const component = defineComponent({
setup() {
api = useScrollspy()
return () => null
}
})
wrapper = await mountSuspended(component)
return { api, observer: MockIntersectionObserver.instances[0]! }
}

it('starts with empty visible and active headings', async () => {
const { api } = await mountSpy()

expect(api.visibleHeadings.value).toEqual([])
expect(api.activeHeadings.value).toEqual([])
})

it('observes the headings passed to updateHeadings', async () => {
const { api, observer } = await mountSpy()
const a = heading('a')
const b = heading('b')

api.updateHeadings([a, b])

expect(observer.observed).toEqual([a, b])
})

it('tracks intersecting headings and mirrors them into active headings', async () => {
const { api, observer } = await mountSpy()
const a = heading('a')
const b = heading('b')
api.updateHeadings([a, b])

observer.trigger([{ target: a, isIntersecting: true }])
expect(api.visibleHeadings.value).toEqual(['a'])
await nextTick()
expect(api.activeHeadings.value).toEqual(['a'])

observer.trigger([{ target: b, isIntersecting: true }])
expect(api.visibleHeadings.value).toEqual(['a', 'b'])
await nextTick()
expect(api.activeHeadings.value).toEqual(['a', 'b'])
})

it('removes headings once they stop intersecting', async () => {
const { api, observer } = await mountSpy()
const a = heading('a')
api.updateHeadings([a])

observer.trigger([{ target: a, isIntersecting: true }])
observer.trigger([{ target: a, isIntersecting: false }])

expect(api.visibleHeadings.value).toEqual([])
})

it('keeps the last active headings when nothing is visible', async () => {
const { api, observer } = await mountSpy()
const a = heading('a')
api.updateHeadings([a])

observer.trigger([{ target: a, isIntersecting: true }])
await nextTick()
expect(api.activeHeadings.value).toEqual(['a'])

observer.trigger([{ target: a, isIntersecting: false }])
await nextTick()

expect(api.visibleHeadings.value).toEqual([])
// Active headings retain the previous value so the ToC keeps a highlight.
expect(api.activeHeadings.value).toEqual(['a'])
})

it('stops observing previous headings when updating', async () => {
const { api, observer } = await mountSpy()
const a = heading('a')
const b = heading('b')

api.updateHeadings([a])
observer.trigger([{ target: a, isIntersecting: true }])
await nextTick()
expect(api.activeHeadings.value).toEqual(['a'])

// e.g. page navigation: old targets are dropped instead of accumulating.
api.updateHeadings([b])

expect(observer.observed).toEqual([b])
expect(api.visibleHeadings.value).toEqual([])
await nextTick()
// The last highlight is retained until new intersections arrive.
expect(api.activeHeadings.value).toEqual(['a'])

observer.trigger([{ target: b, isIntersecting: true }])
await nextTick()
expect(api.activeHeadings.value).toEqual(['b'])
})

it('ignores entries whose target has no id', async () => {
const { api, observer } = await mountSpy()

observer.trigger([{ target: document.createElement('div'), isIntersecting: true }])

expect(api.visibleHeadings.value).toEqual([])
})
})