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
2 changes: 1 addition & 1 deletion .sync/dep-parity.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$note": "Upstream's pinned versions for every dependency both trees declare in the SAME section, snapshotted at `cursor`. The sync ports deltas, which is correct per commit and lets a one-time divergence become permanent: once a version is off upstream's line, every later `chore(deps)` batch skips it, because those ports bump only where this fork already matched upstream's pre-image. `prettier` sat at ^3.8.4 against upstream's ^3.9.6 for that reason, through four ported batches, until this file was written. Section-aware on purpose: 18 packages are declared on both sides but in different sections — the whole `@tiptap/*` family is a peer `^3` upstream and a dependency `^3.29.2` here, and `ai` is a peer there and a devDependency here. Those are structural divergences, not drift, and comparing a peer range against a dependency range says nothing. They are absent from this file by construction rather than by omission. Guarded by `test/utils/dep-parity.spec.ts`. Refresh with `node .sync/dep-parity.mjs <path-to-nuxt-ui-mirror> [cursor]`, which preserves `exceptions`.",
"cursor": "612ab1cb03f2aba546d57a2e1813d73cf205ac43",
"cursor": "8a25c22b38b139642fec328ae7c73a235a5b0b5a",
"manifests": {
"package.json": {
"dependencies": {
Expand Down
64 changes: 64 additions & 0 deletions .sync/log/8a25c22b38b139642fec328ae7c73a235a5b0b5a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# port — nuxt/ui@8a25c22b38b139642fec328ae7c73a235a5b0b5a

**Upstream:** test(Link): wait for the visibility observer instead of a fixed timeout

**Decision:** port, verbatim.

Third commit in the `Link` prefetch chain, after `c4ee0ea3` (#538) and `612ab1cb`
(#541). Test-only — no runtime file moves.

## Upstream change

`612ab1cb` put the visibility observer behind `onNuxtReady`, so registration now
runs through two deferrals instead of one: `onNuxtReady`, then the idle callback,
each falling back to a short timeout under happy-dom. The spec still waited a
flat 10 ms and then read `MockIntersectionObserver.instances[0]!`, which is a
non-null assertion on a value that may not be there yet.

Three edits, all in `test/components/nuxt/LinkPrefetch.spec.ts`:

- a `waitForObserver()` helper built on `vi.waitFor`, polling until exactly one
observer exists and returning it;
- the positive case uses it instead of `await idle()`;
- `idle()` itself goes 10 ms → 50 ms, for the two **negative** cases, which
cannot poll for something that is meant never to appear and so can only give
the chain time to run.

## b24ui port

Applies verbatim. `test/components/nuxt/LinkPrefetch.spec.ts` is **byte-identical**
to upstream's pre-image — checked with a full-file `diff` against
`612ab1cb:test/components/nuxt/LinkPrefetch.spec.ts`, not just at the hunks — so
`git apply` lands it unchanged. `vi` and `expect` were already imported.

## What the port actually buys, measured

**Not a fix for a failure reproducible here.** Instrumented the positive case
with a 1 ms polling loop around `MockIntersectionObserver.instances`: registration
completes in **2 ms**, three runs out of three. The old 10 ms had a 5× margin on
this machine, and the suite was green on `main` after #541. So the change guards
against a loaded CI runner rather than repairing a break — worth stating plainly,
because the commit subject reads like a fix and the log would otherwise imply one.

The measurement also corrects a reading of `vi.waitFor`: timing the helper itself
reports 51 ms, which is its poll interval, not the registration time. The two
numbers differ by 25× and only the smaller one is about the code under test.

**One genuinely new assertion**, and it is not the timing one. The old line
`instances[0]!` looks at the first observer and ignores any others;
`waitForObserver` asserts `toHaveLength(1)`. `observeIntersection` shares a single
`IntersectionObserver` across every link and tears it down once the last caller
unobserves — so "exactly one" is the invariant that sharing holds, and nothing
asserted it before.

Mutation-checked:

- making `observeIntersection` return early without observing → **1 failed**, so
the `waitFor` is not vacuously satisfied; it times out rather than passing on
an empty list.
- constructing a second `IntersectionObserver` per call (sharing broken, the
observed behaviour otherwise identical) → **1 failed** on the port, and
**5 passed** on the pre-image. That is the coverage this commit adds, isolated:
the same mutation is invisible to the spec as it stood.

Both negative cases stay as they are; the 50 ms only lengthens them.
8 changes: 7 additions & 1 deletion .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"upstream": "nuxt/ui",
"branch": "v4",
"cursor": "612ab1cb03f2aba546d57a2e1813d73cf205ac43",
"cursor": "8a25c22b38b139642fec328ae7c73a235a5b0b5a",
"_cursor_note": "cursor = last upstream commit ported into b24ui. The sync is manual by decision: one commit at a time, oldest-first, each with a `.sync/log/<sha>.md` journal and an entry in `processed`. There is no dispatcher, no porter workflow and no kill-switch — `.sync/PORTING.md` is the whole procedure. `processed` is maintained per port (backfilled #68-#72 on 2026-06-09).",
"processed": {
"2799fa6f2b25ce3eb15e050f3ef7c57d0d9a2fdb": {
Expand Down Expand Up @@ -1751,6 +1751,12 @@
"b24ui_sha": "89f81a79a844c5eca59ad317bfa2474fe157fed1",
"decision": "port",
"summary": "fix(Link): wait for `onNuxtReady` before observing visibility (nuxt/ui #6922) — PORT, verbatim. A follow-up to c4ee0ea3, ported here one day earlier in #538; it corrects a defect in that fix rather than adding anything, so this fork shipped the defect too, for the length of one PR. The defect: onMounted runs before hydration finishes, Nuxt's payload plugin registers its link:prefetch listener onNuxtReady, and prefetch() sets `prefetched` before dispatching — so firing too early fails both halves together, nobody is listening so nothing is fetched, and the link is marked prefetched so shouldPrefetch refuses every later attempt. Not 'prefetching happens sooner than ideal': the link is retired without ever having fetched anything, the same silent shape as the regression c4ee0ea3 itself fixed, arriving from the other side. The fix moves registration inside onNuxtReady and adds an `unmounted` flag so a component torn down before hydration does not register an observer afterwards — onBeforeUnmount cannot cancel an idle callback that has not been scheduled yet. Verbatim port; our pre-image is upstream's line for line, being its own code taken a day earlier, with the comment extended by one clause to say what the ordering costs. The tests do NOT cover this, measured rather than assumed: replacing onNuxtReady with an immediate call (i.e. reverting to #538) leaves 5 passed, and removing the unmounted guard leaves 5 passed; upstream added no test either. The reason is structural and was measured — instrumenting callback order here gives 'before -> after -> callback', so onNuxtReady does defer, but the spec's idle() helper waits 10ms which covers both the deferral and the idle callback, so the ordering changes while the observable outcome inside that window does not. Underneath that, the defect cannot reproduce in this spec at all: it is about prefetching before the payload plugin has registered its listener, and the spec registers the listener by hand before mounting via useNuxtApp().hooks.hook('link:prefetch', spy) — a listener that is always present cannot be missed. Taken anyway because the reasoning is checkable in the source and the change costs nothing; coverage is not claimed. Third entry with that shape after 6caa6a95 and its own parent c4ee0ea3 where the tests DID see the port — the difference each time is whether the assertion can reach the path, not how carefully the test is written."
},
"8a25c22b38b139642fec328ae7c73a235a5b0b5a": {
"pr": "pending-merge",
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "test(Link): wait for the visibility observer instead of a fixed timeout (nuxt/ui, no PR ref in the subject) — PORT, verbatim. Third in the Link prefetch chain after c4ee0ea3 (#538) and 612ab1cb (#541), and a consequence of the second: onNuxtReady put a second deferral in front of the idle callback, so the spec's flat 10ms wait sat in front of a two-step chain while still doing a non-null `instances[0]!`. Upstream adds a vi.waitFor-based waitForObserver() for the positive case and lifts idle() to 50ms for the two negative cases, which cannot poll for something meant never to appear. Applies verbatim: the file is BYTE-IDENTICAL to upstream's pre-image, checked with a whole-file diff against 612ab1cb rather than at the hunks. Measured rather than assumed, and the measurement cuts against the commit subject: registration completes in 2ms here, three runs of three, instrumented with a 1ms polling loop — so the old 10ms had a 5x margin and nothing was failing; this guards a loaded CI runner rather than repairing a break. Timing waitFor itself reports 51ms, which is its poll interval and not the registration time; the two differ by 25x and only the smaller is about the code under test. The real gain is an assertion, not a timeout: `instances[0]!` reads the first observer and ignores any others, while waitForObserver asserts toHaveLength(1) — and observeIntersection shares ONE IntersectionObserver across every link, tearing it down when the last caller unobserves, so 'exactly one' is the invariant that sharing holds and nothing asserted it before. Mutation-checked both ways: returning early from observeIntersection without observing turns 1 red (the waitFor times out rather than passing on an empty list), and constructing a second observer per call turns 1 red on the port while leaving 5 passed on the pre-image — that mutation isolated is the coverage this commit adds. dep-parity refreshed at the new cursor: the cursor line is the only change, the commit touches no manifest."
}
}
}
18 changes: 12 additions & 6 deletions test/components/nuxt/LinkPrefetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,18 @@ class MockIntersectionObserver {
}
}

// The visibility observer is registered from an idle callback, which falls back
// to a short timeout in happy-dom.
// The visibility observer is registered `onNuxtReady` and then from an idle
// callback, both falling back to short timeouts in happy-dom. Positive checks
// poll for the observer, negative ones give the same chain time to run.
function idle() {
return new Promise(resolve => setTimeout(resolve, 10))
return new Promise(resolve => setTimeout(resolve, 50))
}

function waitForObserver() {
return vi.waitFor(() => {
expect(MockIntersectionObserver.instances).toHaveLength(1)
return MockIntersectionObserver.instances[0]!
})
}

describe('Link prefetch', () => {
Expand Down Expand Up @@ -96,9 +104,7 @@ describe('Link prefetch', () => {
wrapper = await mountSuspended(Link, { props: { to: '/about' }, slots: { default: () => 'About' } })
const link = wrapper.get('a').element

await idle()

const observer = MockIntersectionObserver.instances[0]!
const observer = await waitForObserver()
expect(observer.observed).toEqual([link])
expect(spy).not.toHaveBeenCalled()

Expand Down