perf(desktop): bound warm timeline work

Skip redundant fresh-window catch-up on warm navigation, memoize stable rows,
and narrow the retained DOM to the active viewport and tail. Strengthen the
warm-switch and scroll regression gates, including buffered live-tail recovery.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-15 12:03:09 -06:00
co-authored by Carl
parent 6b1058e754
commit a12e35bfcc
9 changed files with 293 additions and 72 deletions
+24 -15
View File
@@ -229,6 +229,7 @@ export function resolveThreadReplyTarget(
}
export const CHANNEL_TIMELINE_GC_TIME_MS = 60 * 60 * 1_000;
export const CHANNEL_TIMELINE_STALE_TIME_MS = 5 * 60 * 1_000;
export function useChannelWindowQuery(channel: Channel | null) {
const queryClient = useQueryClient();
@@ -372,7 +373,7 @@ export function useChannelMessagesQuery(channel: Channel | null) {
signal,
);
},
staleTime: 5 * 60 * 1_000,
staleTime: CHANNEL_TIMELINE_STALE_TIME_MS,
gcTime: CHANNEL_TIMELINE_GC_TIME_MS,
});
}
@@ -498,19 +499,27 @@ export function useChannelSubscription(channel: Channel | null) {
}
cleanup = dispose;
// The live subscription starts at "now", so it cannot close the gap
// between the last page snapshot and subscription establishment. Always
// refresh after the subscription is active; freshness alone is not a
// proof that no relay events landed in that interval.
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel window after subscribing",
channelId,
error,
);
}
});
// A fresh cached window already covers ordinary warm navigation. Only
// close the page-to-live gap when the snapshot is absent or old;
// reconnects remain an explicit unconditional catch-up above.
const queryState = queryClient.getQueryState<RelayEvent[]>(
channelMessagesKey(channelId),
);
const snapshotIsFresh =
queryState?.data !== undefined &&
Date.now() - queryState.dataUpdatedAt <
CHANNEL_TIMELINE_STALE_TIME_MS;
if (!snapshotIsFresh) {
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel window after subscribing",
channelId,
error,
);
}
});
}
})
.catch((error) => {
console.error("Failed to subscribe to channel", channelId, error);
@@ -523,7 +532,7 @@ export function useChannelSubscription(channel: Channel | null) {
void cleanup();
}
};
}, [channelId, channelType]);
}, [channelId, channelType, queryClient]);
}
export function useSendMessageMutation(
@@ -739,7 +739,9 @@ const MessageTimelineBase = React.forwardRef<
{useTimelineVirtualizer && timelineList ? (
<div
className="h-full min-h-0 w-full"
data-live-message-count={messages.length}
data-render-pending={isRenderPending ? "true" : undefined}
data-rendered-message-count={renderedMessages.length}
>
{timelineList}
</div>
@@ -844,7 +846,7 @@ const MessageTimelineBase = React.forwardRef<
)}
</div>
{!isAtBottom ? (
{!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center px-4",
@@ -91,7 +91,7 @@ type MessageRowItemProps = {
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
};
export function MessageRowItem({
export const MessageRowItem = React.memo(function MessageRowItem({
channelId,
currentPubkey,
entry,
@@ -122,6 +122,13 @@ export function MessageRowItem({
unfollowThreadById,
videoReviewContext,
}: MessageRowItemProps) {
if (import.meta.env.MODE === "e2e" && typeof window !== "undefined") {
const probe = window as unknown as {
__TIMELINE_ROW_RENDER_COUNT__?: number;
};
probe.__TIMELINE_ROW_RENDER_COUNT__ =
(probe.__TIMELINE_ROW_RENDER_COUNT__ ?? 0) + 1;
}
const { message, summary } = entry;
const canManage = canManageMessageForCurrentUser(
message,
@@ -225,4 +232,4 @@ export function MessageRowItem({
{footer}
</div>
);
}
});
@@ -14,11 +14,11 @@ export function nextRetainedTimelineKeys(
const offset = list.scrollOffset;
const indexAt = (target: number) =>
list.findItemIndex(Math.min(list.scrollSize, Math.max(0, target)));
const admissionStart = indexAt(offset - viewportSize * 8);
const admissionEnd = indexAt(offset + viewportSize * 9);
const evictionStart = indexAt(offset - viewportSize * 12);
const evictionEnd = indexAt(offset + viewportSize * 13);
const tailStart = indexAt(list.scrollSize - viewportSize * 3);
const admissionStart = indexAt(offset - viewportSize * 2);
const admissionEnd = indexAt(offset + viewportSize * 2);
const evictionStart = indexAt(offset - viewportSize * 3);
const evictionEnd = indexAt(offset + viewportSize * 3);
const tailStart = indexAt(list.scrollSize - viewportSize);
const next = new Set<string>();
for (let index = evictionStart; index <= evictionEnd; index += 1) {
@@ -67,20 +67,26 @@ it("does not keep the full timeline mounted before the viewport is measured", as
const root = createRoot(document.getElementById("root"));
await act(async () => root.render(React.createElement(Harness)));
assert.equal(retention.retainedIndices.length, 100);
assert.equal(retention.retainedIndices[0], 9_900);
assert.equal(retention.retainedIndices.length, 8);
assert.equal(retention.retainedIndices[0], 9_992);
assert.equal(retention.retainedIndices.at(-1), 9_999);
await act(async () => initialRefresh());
assert.ok(retention.retainedIndices.length > 0);
assert.ok(retention.retainedIndices.length < 500);
assert.equal(retention.retainedIndices.length, 51);
assert.ok(retention.retainedIndices.includes(4_980));
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(5_020));
assert.ok(!retention.retainedIndices.includes(4_979));
assert.ok(!retention.retainedIndices.includes(5_021));
assert.ok(retention.retainedIndices.includes(9_990));
assert.ok(retention.retainedIndices.includes(9_999));
await act(async () => retention.onScrollEnd());
assert.ok(retention.retainedIndices.length > 0);
assert.ok(retention.retainedIndices.length < 500);
assert.equal(retention.retainedIndices.length, 51);
assert.ok(retention.retainedIndices.includes(4_980));
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(5_020));
assert.ok(retention.retainedIndices.includes(9_990));
assert.ok(retention.retainedIndices.includes(9_999));
await act(async () => root.unmount());
@@ -2,7 +2,7 @@ import * as React from "react";
import type { VListHandle } from "virtua";
import { nextRetainedTimelineKeys } from "./timelineRetention";
const INITIAL_RETAINED_TAIL_SIZE = 100;
const INITIAL_RETAINED_TAIL_SIZE = 8;
export function useTimelineRetention(
keys: readonly string[],
+4 -4
View File
@@ -5139,10 +5139,6 @@ async function handleGetChannelWindow(
return relayQuery(config, [filter]);
};
if (!args.cursor) {
return execute();
}
const probe = window as unknown as {
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
__CHANNEL_WINDOW_INFLIGHT__?: number;
@@ -5151,6 +5147,10 @@ async function handleGetChannelWindow(
probe.__CHANNEL_WINDOW_FETCH_COUNT__ =
(probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1;
if (!args.cursor) {
return execute();
}
const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0;
if (delayMs <= 0) {
return execute();
+30 -11
View File
@@ -236,6 +236,19 @@ test.describe("list virtualization", () => {
);
}, expectedId);
// Transfer scroll ownership away from initial bottom settling with the same
// native input path a reader uses. The deterministic scrollTop assignments
// below position each boundary crossing; by themselves they do not emit
// wheel/pointer/touch intent and therefore cannot retire bottom settling.
const initialBox = await timeline.boundingBox();
if (!initialBox) throw new Error("timeline has no bounding box");
await page.mouse.move(
initialBox.x + initialBox.width / 2,
initialBox.y + initialBox.height / 2,
);
await page.mouse.wheel(0, -1);
await page.waitForTimeout(50);
// Load fifteen consecutive server pages in one mounted virtualizer. This
// is the production shape that exposed the intermittent end-cache snap:
// variable-height rows and repeated front insertions exercise the full
@@ -563,16 +576,18 @@ test.describe("list virtualization", () => {
});
});
test("thread-heavy history mounts every loaded row", async ({ page }) => {
test("thread-heavy history keeps a bounded painted viewport", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed summaries on 120 loaded roots. Every loaded row should be realized
// immediately so first-pass scrolling never encounters Virtua's hidden
// pre-measurement state.
// Seed summaries on 120 loaded roots. The bounded retention window should
// still paint every mounted row, without realizing the complete 50-root
// relay page before the reader moves through it.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -603,9 +618,15 @@ test("thread-heavy history mounts every loaded row", async ({ page }) => {
await page.waitForTimeout(300);
const loadedRows = timeline.locator("[data-message-id]");
// The mock channel's current loaded window contains 50 roots; all of them
// must already exist and be painted before the first scroll gesture.
await expect(loadedRows).toHaveCount(50);
const loadedRowCount = await loadedRows.count();
// The current relay window still carries 50 roots, while only the viewport
// plus the narrow keep-mounted bands should exist as live row DOM.
expect(loadedRowCount).toBeGreaterThan(6);
expect(loadedRowCount).toBeLessThan(50);
const completeSnapshotCount = await page
.locator("[data-live-message-count]")
.evaluate((element) => Number(element.dataset.liveMessageCount ?? "0"));
expect(completeSnapshotCount).toBeGreaterThan(loadedRowCount);
expect(
await loadedRows.evaluateAll((rows) =>
rows.every((row) => getComputedStyle(row).visibility === "visible"),
@@ -810,10 +831,8 @@ test("live tail arrivals stay buffered while reading and release on jump", async
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await timeline.hover();
await page.mouse.wheel(0, -800);
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
const frozenHeight = await timeline.evaluate(
(element) => element.scrollHeight,
+205 -27
View File
@@ -9,23 +9,21 @@ import { installMockBridge } from "../helpers/bridge";
* already in the React Query cache (the everyday alt-tab-between-channels
* motion). The timeline subtree is keyed by channel id required so TanStack
* Router's scroll restoration never writes a stale scrollTop into a reused
* scroll node so every switch unmounts and remounts all rows, and each
* `MessageRow` re-runs the synchronous react-markdown parse pipeline from
* scratch. This spec is the instrument for that cost.
* scroll node. Warm entry therefore mounts a bounded route-correct slice first,
* then releases the complete cached snapshot in one transition. This spec gates
* that cost and proves the release cannot strand a partial timeline.
*
* TWO SCENARIOS, one per axis of the cost:
* plain-text `deep-history` (600 seeded one-line rows; the initial
* channel window mounts ~50 of them, verified by parse
* count): isolates the per-row remount floor.
* plain-text `deep-history` (600 seeded one-line rows; the cached
* channel window retains 50): isolates the remount floor.
* markdown `random` + 60 injected markdown-heavy rows (code fences,
* tables, lists, mentions, links): isolates the parse cost the
* markdown cache is meant to remove.
* tables, lists, mentions, links): exercises expensive visible
* DOM creation and style recalculation.
*
* WHAT A "SWITCH" MEASURES: performance.now() immediately before an in-page
* .click() on the sidebar link, until (chat title flipped) AND (>= 1 message
* row committed) AND (no [data-render-pending="true"], i.e. the deferred
* timeline snapshot caught up to the live one) AND a double-rAF so a frame
* actually painted. The click and the polling both run in-page so CDP
* .click() on the sidebar link, until (chat title flipped) AND the target
* channel's expected visible viewport row is painted at the restored bottom
* offset with the complete cached snapshot admitted. The click and the
* round-trip latency never pollutes the numbers. Longtask totals are captured
* per switch as the "UI froze" axis (see cold-switch-longtask.perf.ts for the
* rationale).
@@ -37,8 +35,8 @@ import { installMockBridge } from "../helpers/bridge";
* the same machine are.
*
* Run it (from desktop/):
* pnpm build
* npx playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts
* pnpm build:e2e
* pnpm exec playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts
*
* NOTE: the perf web server reuses an existing server on :4173 if one is
* already running, kill it or make sure `dist/` is freshly built, otherwise
@@ -48,6 +46,10 @@ import { installMockBridge } from "../helpers/bridge";
const MEASURED_SWITCHES = 8;
const THROTTLE_RATE = 4;
const MARKDOWN_MESSAGE_COUNT = 60;
const PLAIN_MAX_LONGTASK_MS = 300;
const MARKDOWN_MAX_LONGTASK_MS = 300;
const PLAIN_MAX_CORRECT_PAINT_MS = 550;
const MARKDOWN_MAX_CORRECT_PAINT_MS = 325;
/** One representative agent-style message: fence, table, list, mention,
* emphasis, inline code, and a link the mix real Buzz channels carry. */
@@ -80,6 +82,19 @@ type SwitchSample = {
longtaskTotal: number;
longtaskMax: number;
longtaskCount: number;
liveMessageCount: number;
renderedMessageCount: number;
visibleMessageIds: string[];
distanceFromBottom: number;
rowRenderCount: number;
mountedRowCount: number;
};
type ScenarioResult = {
samples: SwitchSample[];
cachedMessageCount: number;
expectedVisibleMessageIds: string[];
windowFetches: number;
};
function median(values: number[]): number {
@@ -108,15 +123,19 @@ async function waitForMockLiveSubscription(
}
/** Click the sidebar link and poll all in-page until the target channel's
* rows are committed, the deferred snapshot has caught up, and a frame
* painted. Returns wall-clock ms plus the longtasks observed in the window. */
* correct visible viewport is committed and a frame paints. Returns wall-clock
* ms plus the longtasks observed in the window. */
async function measureSwitch(
page: import("@playwright/test").Page,
input: { targetTestId: string; targetTitle: string; rowSelector: string },
): Promise<SwitchSample> {
return page.evaluate(async (args) => {
const store = window as unknown as { __LONGTASKS__: number[] };
const store = window as unknown as {
__LONGTASKS__: number[];
__TIMELINE_ROW_RENDER_COUNT__?: number;
};
store.__LONGTASKS__ = [];
store.__TIMELINE_ROW_RENDER_COUNT__ = 0;
const link = document.querySelector<HTMLElement>(
`[data-testid="${args.targetTestId}"]`,
);
@@ -131,10 +150,27 @@ async function measureSwitch(
const title = document.querySelector(
'[data-testid="chat-title"]',
)?.textContent;
const timeline = document.querySelector<HTMLDivElement>(
'[data-testid="message-timeline"]',
);
const counts = document.querySelector<HTMLElement>(
"[data-rendered-message-count]",
);
const snapshotsMatch =
Number(counts?.dataset.liveMessageCount ?? "0") > 0 &&
counts?.dataset.renderedMessageCount ===
counts?.dataset.liveMessageCount;
const atBottom = timeline
? timeline.scrollHeight -
timeline.clientHeight -
timeline.scrollTop <=
2
: false;
const ready =
title === args.targetTitle &&
document.querySelector(args.rowSelector) !== null &&
document.querySelector('[data-render-pending="true"]') === null;
snapshotsMatch &&
atBottom;
if (ready) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
return;
@@ -150,11 +186,46 @@ async function measureSwitch(
const elapsed = performance.now() - start;
const tasks = store.__LONGTASKS__ ?? [];
const snapshot = document.querySelector<HTMLElement>(
"[data-rendered-message-count]",
);
const timeline = document.querySelector<HTMLDivElement>(
'[data-testid="message-timeline"]',
);
const timelineRect = timeline?.getBoundingClientRect();
const visibleMessageIds =
timeline && timelineRect
? Array.from(
timeline.querySelectorAll<HTMLElement>("[data-message-id]"),
)
.filter((row) => {
const rect = row.getBoundingClientRect();
return (
rect.bottom > timelineRect.top && rect.top < timelineRect.bottom
);
})
.map((row) => row.dataset.messageId ?? "")
.filter(Boolean)
: [];
const distanceFromBottom = timeline
? timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop
: Number.POSITIVE_INFINITY;
const liveMessageCount = Number(snapshot?.dataset.liveMessageCount ?? "0");
const renderedMessageCount = Number(
snapshot?.dataset.renderedMessageCount ?? "0",
);
return {
ms: elapsed,
longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0),
longtaskMax: tasks.length ? Math.max(...tasks) : 0,
longtaskCount: tasks.length,
liveMessageCount,
renderedMessageCount,
visibleMessageIds,
distanceFromBottom,
rowRenderCount: store.__TIMELINE_ROW_RENDER_COUNT__ ?? 0,
mountedRowCount:
timeline?.querySelectorAll("[data-message-id]").length ?? 0,
};
}, input);
}
@@ -167,17 +238,27 @@ async function runScenario(
targetTitle: string;
rowSelector: string;
},
): Promise<SwitchSample[]> {
): Promise<ScenarioResult> {
const back = {
targetTestId: "channel-general",
targetTitle: "general",
rowSelector: "[data-message-id]",
};
// Untimed warmup round-trip: caches both channels' queries.
await measureSwitch(page, input);
// Untimed warmup round-trip: caches both channels' queries. Its settled
// message count is the complete cached window every measured re-entry must
// release after the bounded first paint.
const warmup = await measureSwitch(page, input);
const cachedMessageCount = warmup.liveMessageCount;
const expectedVisibleMessageIds = warmup.visibleMessageIds;
await measureSwitch(page, back);
await page.evaluate(() => {
(
window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }
).__CHANNEL_WINDOW_FETCH_COUNT__ = 0;
});
const samples: SwitchSample[] = [];
for (let run = 0; run < MEASURED_SWITCHES; run += 1) {
samples.push(await measureSwitch(page, input));
@@ -195,6 +276,11 @@ async function runScenario(
console.log(
`per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`row renders / mounted: [${samples
.map((sample) => `${sample.rowRenderCount}/${sample.mountedRowCount}`)
.join(", ")}]`,
);
console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`);
console.log(
`MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`,
@@ -203,7 +289,53 @@ async function runScenario(
`worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`,
);
/* eslint-enable no-console */
return samples;
const windowFetches = await page.evaluate(
() =>
(window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number })
.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0,
);
return {
samples,
cachedMessageCount,
expectedVisibleMessageIds,
windowFetches,
};
}
async function fetchOneOlderWindow(
page: import("@playwright/test").Page,
): Promise<number> {
return page.evaluate(async () => {
const timeline = document.querySelector<HTMLDivElement>(
'[data-testid="message-timeline"]',
);
if (!timeline) throw new Error("missing message timeline");
const probe = window as unknown as {
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
};
probe.__CHANNEL_WINDOW_FETCH_COUNT__ = 0;
for (let step = 0; step < 400; step += 1) {
timeline.scrollBy(0, -300);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => window.setTimeout(resolve, 25)),
);
if ((probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) > 0) break;
}
const deadline = performance.now() + 5_000;
while (document.querySelector('[data-render-pending="true"]') !== null) {
if (performance.now() > deadline) {
throw new Error("older window did not finish rendering");
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
}
// Give a faulty re-armed observer time to issue a duplicate request.
await new Promise((resolve) => window.setTimeout(resolve, 250));
return probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0;
});
}
test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async ({
@@ -285,9 +417,55 @@ test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
// Instrument, not a gate: assert the harness measured real work.
expect(plain.length).toBe(MEASURED_SWITCHES);
expect(markdown.length).toBe(MEASURED_SWITCHES);
expect(plain.every((sample) => sample.ms > 0)).toBe(true);
expect(markdown.every((sample) => sample.ms > 0)).toBe(true);
expect(plain.samples).toHaveLength(MEASURED_SWITCHES);
expect(markdown.samples).toHaveLength(MEASURED_SWITCHES);
expect(plain.cachedMessageCount).toBeGreaterThan(6);
expect(markdown.cachedMessageCount).toBeGreaterThan(6);
expect(plain.expectedVisibleMessageIds.length).toBeGreaterThan(0);
expect(markdown.expectedVisibleMessageIds.length).toBeGreaterThan(0);
expect(
plain.samples.every(
(sample) =>
sample.liveMessageCount === plain.cachedMessageCount &&
sample.visibleMessageIds.some((id) =>
plain.expectedVisibleMessageIds.includes(id),
) &&
sample.distanceFromBottom <= 2,
),
).toBe(true);
expect(
markdown.samples.every(
(sample) =>
sample.liveMessageCount === markdown.cachedMessageCount &&
sample.visibleMessageIds.some((id) =>
markdown.expectedVisibleMessageIds.includes(id),
) &&
sample.distanceFromBottom <= 2,
),
).toBe(true);
expect(plain.windowFetches).toBe(0);
expect(markdown.windowFetches).toBe(0);
expect(
Math.max(...plain.samples.map((sample) => sample.longtaskMax)),
).toBeLessThanOrEqual(PLAIN_MAX_LONGTASK_MS);
expect(
Math.max(...markdown.samples.map((sample) => sample.longtaskMax)),
).toBeLessThanOrEqual(MARKDOWN_MAX_LONGTASK_MS);
expect(
Math.max(...plain.samples.map((sample) => sample.ms)),
).toBeLessThanOrEqual(PLAIN_MAX_CORRECT_PAINT_MS);
expect(
Math.max(...markdown.samples.map((sample) => sample.ms)),
).toBeLessThanOrEqual(MARKDOWN_MAX_CORRECT_PAINT_MS);
// A genuine older-history reach remains network-backed and bounded to one
// channel-window request. The warm-navigation assertions above prove the
// same counter stays at zero when no continuation is needed.
await measureSwitch(page, {
targetTestId: "channel-deep-history",
targetTitle: "deep-history",
rowSelector: '[data-message-id^="mock-deep-history-"]',
});
expect(await fetchOneOlderWindow(page)).toBe(1);
});