diff --git a/docs/images/message-bubbles-dark.png b/docs/images/message-bubbles-dark.png new file mode 100644 index 00000000..8d795c2e Binary files /dev/null and b/docs/images/message-bubbles-dark.png differ diff --git a/docs/images/message-bubbles-light.png b/docs/images/message-bubbles-light.png new file mode 100644 index 00000000..020f0355 Binary files /dev/null and b/docs/images/message-bubbles-light.png differ diff --git a/docs/images/message-bubbles-thread.png b/docs/images/message-bubbles-thread.png new file mode 100644 index 00000000..9b545ad3 Binary files /dev/null and b/docs/images/message-bubbles-thread.png differ diff --git a/patches/README.md b/patches/README.md index b7403138..1c82521f 100644 --- a/patches/README.md +++ b/patches/README.md @@ -41,8 +41,9 @@ CSS, forced layout, alternate store sizing, or new scroll scheduler is introduce The platform predicate requires MacIntel and Apple vendor, excluding Virtua's iOS detector (including desktop-mode iPad). Chrome/Firefox, non-Mac WebKit and iOS keep -existing policy. Store/layout/observer timing and imperative smooth/instant -navigation remain stock. Scheduler-driven reveal/restore/bottom navigation is a +existing correction policy. Store/layout updates, ordinary row observation and +imperative smooth/instant navigation retain upstream timing. Registrations made +inside resize delivery are deferred as described below. Scheduler-driven reveal/restore/bottom navigation is a separate acceptance path, not implicitly repaired by the automatic-correction fix. Native reveal controls showed one/two transient blank interior source frames before immediate recovery, despite valid sampled DOM coverage. This remaining @@ -50,6 +51,26 @@ imperative-path flicker is not the sustained automatic-correction failure; the patch does not claim to fix it. The stale source-map directive is removed because the generated map is unpatched. +## Observation during resize delivery + +Compact grouped bubbles can cause measured rows to reveal more siblings. When +Virtua synchronously renders and observes those siblings inside ResizeObserver, +WebKit reports skipped notifications at the same DOM depth. + +Only registrations made during resize delivery wait until the next animation +frame. Ordinary mounting (including before driver attachment), size delivery, +adaptive estimates, and scroll corrections keep their upstream timing. The row's +owner window schedules the frame. Unobserving removes a pending registration; +disposal cancels the frame and clears pending rows. Hidden rows stay observed so +later visibility changes can be measured. A `finally` resets the delivery flag +if a subscriber throws. This extends only the already-patched React ESM element +scroller; no other adapter or window scroller is changed. + +Installed-bundle tests cover reentrant registration, synchronous measurements, +pre-attachment cleanup, hidden/reassigned rows, cancellation, remount and errors. +Strict membership, pagination, cold restoration and opening browser checks cover +the integration without suppressing observer errors or changing timing budgets. + ## Automated checks ```sh @@ -61,7 +82,7 @@ bin/pnpm test:browser history-loading.spec.mjs image-scroll.spec.mjs initial-pos --project chromium --project webkit --no-deps --workers=1 ``` -The 13 driver/store/observer contracts evaluate the installed React ESM, not a +The 18 driver/store/observer contracts evaluate the installed React ESM, not a copied implementation. They cover active and inferred-idle corrections, zero jumps, positive/negative measurements, absolute edges, horizontal RTL, overlapping restoration, CSS priority, disposal/remount, later declarations, platform controls, diff --git a/patches/virtua@0.51.0.patch b/patches/virtua@0.51.0.patch index a1d36783..48d2229c 100644 --- a/patches/virtua@0.51.0.patch +++ b/patches/virtua@0.51.0.patch @@ -1,18 +1,24 @@ diff --git a/lib/index.js b/lib/index.js +index a183cc7b1669aab25467840540ce3bd1c025fb47..483f3d281edf5640fd99abe5104ceccd82bcaec3 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -7,1 +7,5 @@ +@@ -6,6 +6,10 @@ import { jsx as c } from "react/jsx-runtime"; + import { flushSync as f } from "react-dom"; -+ + +// WebKit can restore a stale native scroll offset after an instant correction. +// Interrupt only the corrected axis; observer-inferred idle is not native idle. +const isMacWebKit = () => typeof navigator !== "undefined" && !b() && navigator.platform === "MacIntel" && navigator.vendor === "Apple Computer, Inc."; -@@ -249,7 +253,22 @@ ++ + var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v = e => [ ...e ].sort((e, t) => e - t), p = setTimeout, S = clearTimeout, m = "function" == typeof queueMicrotask ? queueMicrotask : e => { + Promise.resolve().then(e); + }, $ = () => { +@@ -249,24 +253,58 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v o && o(); } ]; }, E = (e, t) => { - let n, o, r = $(), i = !1; -+ let n, o, r = $(), i = !1, restoreOverflow; ++ let n, o, r = $(), i = !1, restoreOverflow, deliveringResize = false, observationFrame, observationWindow; + const interruptMomentum = () => { + // Repeated corrections in one task share the original declaration. + restoreOverflow && restoreOverflow(); @@ -31,7 +37,40 @@ diff --git a/lib/index.js b/lib/index.js const s = t ? "scrollLeft" : "scrollTop", l = t ? "left" : "top", c = t ? "overflowX" : "overflowY", [f, a] = O(e, () => r[0], (e, t) => { n.scrollTo({ [l]: T(e, i), -@@ -266,7 +285,8 @@ + behavior: t ? "smooth" : "instant" + }); +- }), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, h = C(t => { +- const o = []; +- for (const {target: r, contentRect: i} of t) if (r.offsetParent) if (r === n) e.W(4, i[u]); else { +- const e = d.get(r); +- null != e && o.push([ e, i[u] ]); ++ }), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, pendingRows = new Set; ++ const observeRow = row => { ++ if (!deliveringResize) return h.A(row); ++ // Newly rendered siblings must start observation after this delivery cycle. ++ pendingRows.add(row); ++ if (observationFrame === void 0) { ++ observationWindow = z(_(row)); ++ observationFrame = observationWindow.requestAnimationFrame(() => { ++ observationFrame = void 0; ++ for (const target of pendingRows) h.A(target); ++ pendingRows.clear(); ++ }); ++ } ++ }, h = C(t => { ++ deliveringResize = true; ++ try { ++ const o = []; ++ for (const {target: r, contentRect: i} of t) if (r.offsetParent) if (r === n) e.W(4, i[u]); else { ++ const e = d.get(r); ++ null != e && o.push([ e, i[u] ]); ++ } ++ o.length && e.W(3, o); ++ } finally { ++ deliveringResize = false; + } +- o.length && e.W(3, o); + }); return { D(f, u = f.parentElement) { h.A(n = u), t && (i = "rtl" === getComputedStyle(u).direction), o = M(e, u, t, () => T(u[s], i), (t, n, o) => { @@ -41,15 +80,26 @@ diff --git a/lib/index.js b/lib/index.js const e = u.style, t = e[c]; e[c] = "hidden", p(() => { e[c] = t; -@@ -283,7 +303,7 @@ +@@ -283,10 +321,13 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v }), r[1](!0); }, _() { - h.X(), o && o.X(), r[1](!1), r = $(); ++ if (observationFrame !== void 0) observationWindow.cancelAnimationFrame(observationFrame); ++ observationFrame = void 0; ++ pendingRows.clear(); + restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $(); }, - P: (e, t) => (d.set(e, t), h.A(e), () => { - d.delete(e), h.B(e); -@@ -799,2 +819,1 @@ +- P: (e, t) => (d.set(e, t), h.A(e), () => { +- d.delete(e), h.B(e); ++ P: (e, t) => (d.set(e, t), observeRow(e), () => { ++ pendingRows.delete(e), d.delete(e), h.B(e); + }), + V: f, + J() { +@@ -797,4 +838,3 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + }); + export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid }; +\ No newline at end of file -//# sourceMappingURL=index.js.map diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0096c5..3c65a702 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - virtua@0.51.0: 14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d + virtua@0.51.0: 9b2a68ffddc241131ba6203ecb7359d7d11cccc84ef46077856d5a85049b0b70 importers: @@ -79,7 +79,7 @@ importers: version: 4.0.1 virtua: specifier: 0.51.0 - version: 0.51.0(patch_hash=14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.51.0(patch_hash=9b2a68ffddc241131ba6203ecb7359d7d11cccc84ef46077856d5a85049b0b70)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yaml: specifier: 2.8.3 version: 2.8.3 @@ -3472,7 +3472,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.51.0(patch_hash=14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + virtua@0.51.0(patch_hash=9b2a68ffddc241131ba6203ecb7359d7d11cccc84ef46077856d5a85049b0b70)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 97a6c53b..7e0cf49e 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -678,6 +678,7 @@ function ChannelWorkspace({ ); - const button = ( + const triggerProps = { + ref: trigger, + "aria-expanded": open && !disabled, + "aria-busy": (gifDiscoveryRequested && gifs === undefined) || undefined, + "aria-controls": id, + disabled, + onPointerEnter: () => setGifDiscoveryRequested(true), + onFocus: () => setGifDiscoveryRequested(true), + onClick: () => { + if (open) { + setOpen(false); + return; + } + setAnimateTab(false); + setPressedTab(undefined); + void session.emoji.ensure(); + if (gifs !== true && gifAvailability?.community === community) + setGifAvailability(undefined); + setGifDiscoveryRequested(true); + setOpen(true); + }, + }; + const button = reaction ? ( +