diff --git a/.sync/log/59440671dd20196ca3a7cc613dc13438251e706d.md b/.sync/log/59440671dd20196ca3a7cc613dc13438251e706d.md new file mode 100644 index 00000000..78dbae0c --- /dev/null +++ b/.sync/log/59440671dd20196ca3a7cc613dc13438251e706d.md @@ -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. diff --git a/.sync/nuxt-ui.json b/.sync/nuxt-ui.json index 3751d7fa..6d8cbeb4 100644 --- a/.sync/nuxt-ui.json +++ b/.sync/nuxt-ui.json @@ -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, @@ -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." } } } diff --git a/src/runtime/composables/useScrollspy.ts b/src/runtime/composables/useScrollspy.ts index bfadf10e..3de54e0b 100644 --- a/src/runtime/composables/useScrollspy.ts +++ b/src/runtime/composables/useScrollspy.ts @@ -6,28 +6,41 @@ export function useScrollspy() { const activeHeadings = ref([]) 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) => { diff --git a/test/composables/useScrollspy.spec.ts b/test/composables/useScrollspy.spec.ts new file mode 100644 index 00000000..567ccdee --- /dev/null +++ b/test/composables/useScrollspy.spec.ts @@ -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> + + beforeEach(() => { + MockIntersectionObserver.instances = [] + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + wrapper?.unmount() + vi.unstubAllGlobals() + }) + + async function mountSpy() { + let api!: ReturnType + 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([]) + }) +})