Skip to content
Open
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
Binary file added docs/images/message-bubbles-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-bubbles-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-bubbles-thread.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 24 additions & 3 deletions patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,36 @@ 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
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
Expand All @@ -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,
Expand Down
68 changes: 59 additions & 9 deletions patches/virtua@0.51.0.patch
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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) => {
Expand All @@ -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
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.

1 change: 1 addition & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ function ChannelWorkspace({
<ThreadPanel
extensions={extensions}
session={queries}
viewer={viewer}
scope={scope}
channelName={current?.name ?? ""}
channelId={showingThread.channelId}
Expand Down
23 changes: 3 additions & 20 deletions src/bundled/emoji/Emoji.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,31 +55,14 @@
z-index: 100;
}
.reactionPopover {
background: var(--bg-float);
border-color: var(--border-primary);
box-shadow: var(--shadow-sm);
position: relative;
bottom: auto;
left: auto;
max-width: calc(100vw - 32px);
}
.reactionTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-chip);
background: var(--surface-control);
color: var(--text-muted);
cursor: pointer;
}
.reactionTrigger:hover {
color: var(--text);
}
.reactionTrigger:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
.emojiPopover[data-has-tabs] .sharedSearchIcon {
top: 67px;
}
Expand Down
69 changes: 39 additions & 30 deletions src/bundled/emoji/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { Search, Smile, SmilePlus } from "lucide-react";
import { Search, Smile } from "lucide-react";
import { IconMoodPlus } from "@tabler/icons-react";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { Popover } from "@base-ui/react/popover";
import type { RelaySession } from "../../features/relay/session";
import {
Expand Down Expand Up @@ -301,38 +303,45 @@ export function EmojiPicker({
)}
</section>
);
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 ? (
<IconButton
{...triggerProps}
aria-label="Add reaction"
title="Add reaction"
size="compact"
shape="round"
icon={<IconMoodPlus size={16} aria-hidden="true" />}
/>
) : (
<button
ref={trigger}
className={reaction ? styles.reactionTrigger : undefined}
{...triggerProps}
type="button"
aria-label={reaction ? "Add reaction" : "Insert emoji"}
title={reaction ? "Add reaction" : "Insert emoji"}
aria-expanded={open && !disabled}
aria-busy={(gifDiscoveryRequested && gifs === undefined) || undefined}
aria-controls={id}
disabled={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);
}}
aria-label="Insert emoji"
title="Insert emoji"
>
{reaction ? (
<SmilePlus size={18} aria-hidden="true" />
) : (
<Smile size={20} aria-hidden="true" />
)}
<Smile size={20} aria-hidden="true" />
</button>
);
const controlsView = (
Expand Down
9 changes: 7 additions & 2 deletions src/features/conversation/ReactionTool.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Button } from "../../shared/design-system/ui/Button";
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { RelaySession } from "../relay/session";
import type { EventData } from "../relay/events";
Expand Down Expand Up @@ -144,9 +145,13 @@ function ReactionDelivery({
{operation.delivery === "failed"
? "Couldn’t add reaction."
: "Reaction delivery not confirmed."}{" "}
<button type="button" onClick={() => outbox.retry(operation.event.id)}>
<Button
variant="ghost"
size="compact"
onClick={() => outbox.retry(operation.event.id)}
>
Retry reaction
</button>
</Button>
</span>
);
}
Loading
Loading