Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
92 changes: 84 additions & 8 deletions patches/virtua@0.51.0.patch
Original file line number Diff line number Diff line change
@@ -1,14 +1,64 @@
diff --git a/lib/index.js b/lib/index.js
index a183cc7..a892a1c 100644
index a183cc7b..44e2d963 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.";
@@ -252 +256,19 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
+
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);
}, $ = () => {
@@ -173,15 +177,37 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
q: () => [ l.slice(), o ]
};
}, C = e => {
- let t;
+ let t, view, frame;
+ const observed = new Set, pending = new Map;
return {
A(n) {
- (t || (t = new (z(_(n)).ResizeObserver)(e))).observe(n);
+ observed.add(n);
+ if (!t) {
+ view = z(_(n));
+ // Rendering from native resize delivery can mount same-depth
+ // items and trigger WebKit's skipped-notification loop.
+ t = new view.ResizeObserver(entries => {
+ for (const entry of entries) if (observed.has(entry.target)) pending.set(entry.target, entry);
+ if (pending.size && frame === undefined) frame = view.requestAnimationFrame(() => {
+ frame = undefined;
+ const batch = [...pending.values()];
+ pending.clear();
+ if (batch.length) e(batch);
+ });
+ });
+ }
+ t.observe(n);
},
B(e) {
+ observed.delete(e);
+ pending.delete(e);
t.unobserve(e);
},
X() {
+ if (frame !== undefined) view.cancelAnimationFrame(frame);
+ frame = undefined;
+ pending.clear();
+ observed.clear();
t && t.disconnect();
}
};
@@ -249,10 +275,28 @@ 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;
+ // Scroll APIs can truncate fractional offsets. Reach the whole edge without
Expand All @@ -29,20 +79,46 @@ index a183cc7..a892a1c 100644
+ }, timer = p(restore);
+ restoreOverflow = restore;
+ };
@@ -255 +277 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
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),
+ [l]: T(edgeOffset(e), i),
@@ -269 +291,2 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
behavior: t ? "smooth" : "instant"
});
}), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, h = C(t => {
@@ -266,7 +310,8 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
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) => {
- if (o) {
+ if (isMacWebKit()) interruptMomentum();
+ else if (o) {
@@ -277 +300 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
const e = u.style, t = e[c];
e[c] = "hidden", p(() => {
e[c] = t;
@@ -274,7 +319,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
}
const r = e.T() + t;
r <= 0 || r >= e.O() + e.t() - e.o() ? u.scrollTo({
- [l]: T(r, i),
+ [l]: T(edgeOffset(r), i),
@@ -286 +309 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
behavior: "instant"
}) : u.scrollBy({
[l]: T(t, i),
@@ -283,7 +328,7 @@ 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 = $();
+ restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $();
@@ -799,2 +822 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
},
P: (e, t) => (d.set(e, t), h.A(e), () => {
d.delete(e), h.B(e);
@@ -796,5 +841,4 @@ 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 };
-//# sourceMappingURL=index.js.map
\ No newline at end of file
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions src/features/messages/ChannelTimeline.report.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,7 @@ async function openReport(row: HTMLElement) {
name: "More message actions",
});
await user.click(trigger);
await user.click(
await screen.findByRole("menuitem", { name: "Report message" }),
);
await user.click(await screen.findByRole("menuitem", { name: "Report" }));
await screen.findByRole("dialog", { name: "Report message" });
return { user, trigger };
}
Expand Down
50 changes: 49 additions & 1 deletion src/features/messages/ChannelTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1146,7 +1146,10 @@ it("restores an anchor inside a membership group after history joins across a pa
membershipRow("anchor", 2),
membershipRow("newer", 3),
]);
expect(h.handle.scrollToIndex).not.toHaveBeenCalled();
expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(0, {
align: "start",
offset: -42,
});
h.unmount();
});
it("live group growth follows the displayed group index rather than a hidden raw row", () => {
Expand Down Expand Up @@ -1464,3 +1467,48 @@ it("estimated list shrinkage can leave an intermediate gap without becoming read
h.unmount();
expect(h.saved().bottom).toBe(true);
});

it("records a measured shrink before an append hides its queued native scroll clamp", () => {
const h = setup();
h.element.scrollTop = 3038;
h.scroll();
h.append();
h.element.scrollHeight -= 200;
h.element.scrollTop -= 200;
h.measureRows(false); // Mutation delivery precedes the queued native scroll.
h.element.scrollHeight += 300; // A new event arrives before that scroll.
h.dispatchScroll();
h.handle.scrollToIndex.mockClear();
h.flush();
expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(2, {
align: "end",
});
h.unmount();
expect(h.saved().bottom).toBe(true);
});

it.each([false, true])(
"a row refresh before the first measured height preserves cold restoration unless the reader intervenes=%s",
(gesture) => {
const mountedRow = { id: "last", y: 42 };
const mounted = [mountedRow];
const h = setup({
initial: { offset: 80851, bottom: false, anchor: { id: "last", y: 42 } },
mounted,
});
mountedRow.y = 142; // Native cold layout can report the right ID at the wrong Y.
h.dispatchScroll();
if (gesture) h.gesture();
h.handle.scrollToIndex.mockClear();
h.edit(); // Refresh cancels the original observer before any height delivery.
h.handle.scrollToIndex.mockClear();
h.measureRows();
if (gesture) expect(h.handle.scrollToIndex).not.toHaveBeenCalled();
else
expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(1, {
align: "start",
offset: -42,
});
h.unmount();
},
);
41 changes: 27 additions & 14 deletions src/features/messages/ChannelTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { RelaySession } from "../relay/session";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Virtualizer, type VirtualizerHandle } from "virtua";
import { MessageRow } from "./MessageRow";
import { continuesMessageGroup } from "./message-grouping";
import type { Attachment, ChannelWindow } from "../relay/contracts";
import { useRowProfiles } from "../relay/react";
import { geometryFor, geometrySignature } from "./geometry";
Expand Down Expand Up @@ -302,9 +303,7 @@ function Timeline({
? savedPosition.current
: null;
let observer: MutationObserver | undefined;
let correctionPending = false;
const restorePosition = () => {
correctionPending = false;
if (intent.current !== scheduledIntent || !handle.current) return;
if (restore) {
const anchor = restore.anchor;
Expand Down Expand Up @@ -349,8 +348,16 @@ function Timeline({
observer = new MutationObserver(() => {
if (list.style.height === height) return;
height = list.style.height;
// Capture a native clamp while its shrink is still observable.
// Another append can grow the list before the queued scroll event.
if (
!restore &&
measuredPosition.current &&
intent.current === scheduledIntent &&
scroller.current
)
recordPosition(scroller.current);
cancelAnimationFrame(frame);
correctionPending = true;
frame = requestAnimationFrame(restorePosition);
});
observer.observe(list, {
Expand All @@ -364,15 +371,17 @@ function Timeline({
return () => {
cancelAnimationFrame(frame);
observer?.disconnect();
// A row refresh can cancel the late measurement correction. Carry the
// original restoration across it; only newer reader input may retire it.
if (
correctionPending &&
restore &&
!follow.current &&
intent.current === scheduledIntent
) {
savedPosition.current = restore;
// A row refresh can arrive before the first deferred measurement or
// cancel its correction. Keep restoration until newer reader input.
if (restore && !follow.current && intent.current === scheduledIntent) {
// Carry the original geometry with the resolved membership identity.
savedPosition.current =
restore.anchor && restoredAnchor.current
? {
...restore,
anchor: { ...restore.anchor, id: restoredAnchor.current },
}
: restore;
settled.current = false;
}
};
Expand Down Expand Up @@ -525,8 +534,7 @@ function Timeline({
{rows.map((row, index) => {
const day =
index === 0
? queries.channels.get?.(channelId)?.channelType !== "dm" ||
window.hasMore
? true
: new Date(
(rows[index - 1]?.createdAt ?? 0) * 1000,
).toDateString() !==
Expand All @@ -545,6 +553,11 @@ function Timeline({
/>
) : (
<MessageRow
layout={
continuesMessageGroup(rows[index - 1], row)
? "continuation"
: "timeline"
}
session={queries}
scope={scope}
key={row.id}
Expand Down
9 changes: 7 additions & 2 deletions src/features/messages/MediaReviewViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ function ReviewComments({
</strong>
<span>{replies.length}</span>
</div>
{replies.map((row) => (
{replies.map((row, index) => (
<div
key={row.id}
data-review-comment={row.id}
Expand All @@ -584,7 +584,12 @@ function ReviewComments({
agentPubkeys={agentPubkeys}
media={session.media}
onOpenLink={() => false}
day={false}
day={
index === 0 ||
new Date(
(replies[index - 1]?.createdAt ?? 0) * 1000,
).toDateString() !== new Date(row.createdAt * 1000).toDateString()
}
retry={session.messages.retry}
onOpenMediaReview={(_rowId, attachment, seconds) =>
selectAttachment(attachment, seconds)
Expand Down
1 change: 1 addition & 0 deletions src/features/messages/MembershipRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const MembershipRow = memo(function MembershipRow({
<div className={styles.day}>
<span>
{new Date(row.createdAt * 1000).toLocaleDateString(undefined, {
year: "numeric",
weekday: "long",
month: "long",
day: "numeric",
Expand Down
13 changes: 1 addition & 12 deletions src/features/messages/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function MessageActionBar({
}}
/>
)}
<span className={styles.copyLinkShortcut}>
<span>
<IconButton
aria-label="Copy link"
title={link ? "Copy link" : "Message link unavailable"}
Expand Down Expand Up @@ -158,17 +158,6 @@ export function MessageActionBar({
</MenuIcon>
Copy message
</MenuItem>
<MenuItem
disabled={!link || copying}
onClick={() => {
if (link) void copy(() => link, "Link");
}}
>
<MenuIcon>
<LinkIcon />
</MenuIcon>
Copy link
</MenuItem>
{overflowItems}
</AfterMenuClose.Provider>
</MenuPopup>
Expand Down
Loading
Loading