Compare commits

..
Author SHA1 Message Date
LukeParkerDev 4dcfc0c59c fix(ui): re-measure the scrollbar when its reactive inputs change
The removed effect was also what re-ran updateThumb when
verticalScrollAdjustment, the orientation or the thumb mount changed
(mobile-timeline-scroll e2e). Track those inputs explicitly with a
deferred effect so the resize observer still takes the first measurement.
2026-09-19 00:40:19 +10:00
LukeParkerDev 27111277e0 perf(ui): let the resize observer take the first scrollbar measurement
ScrollView measured the viewport synchronously in onMount and again in
an effect on the thumb mount, forcing a layout per ScrollView while the
page was still rendering (updateThumb was 24 ms of self time in the
desktop's startup renderer profile on the Home route). The resize
observer already reports every target once after layout, including a
thumb mount that appears later, so those measurements were redundant.

Thumb behaviour verified unchanged in the packaged desktop: appears when
the viewport shrinks, tracks scrollTop, disappears when it grows.
2026-09-18 23:38:41 +10:00
3 changed files with 13 additions and 37 deletions
@@ -3,11 +3,9 @@ export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
@@ -81,36 +79,11 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
? path.join(process.resourcesPath, executableName())
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = yield* bundledVersion(bundled)
const version = parseCliVersion(yield* run(bundled, ["--version"]))
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
return { version, binary, command: [binary] }
})
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary, so
// the answer is remembered per executable identity and only re-read after an update replaces it.
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
const store = getStore()
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
return cached.version
}
const version = parseCliVersion(yield* run(bundled, ["--version"]))
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
return version
})
type VersionCache = { path: string; identity: string; version: string }
function isVersionCache(value: unknown): value is VersionCache {
if (!value || typeof value !== "object") return false
const cache = value as Record<string, unknown>
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
}
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -5,4 +5,3 @@ export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
+11 -7
View File
@@ -1,4 +1,4 @@
import { createEffect, mergeProps, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
import { createEffect, mergeProps, on, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createStore } from "solid-js/store"
@@ -236,18 +236,22 @@ export function ScrollView(props: ScrollViewProps) {
local.viewportRef(viewportRef)
}
// The observer reports every target once when it starts observing (including a thumb mount that
// appears later), after layout. Measuring here as well would force a synchronous layout per
// ScrollView while the page is still rendering.
createResizeObserver(
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
updateThumb,
)
updateThumb()
})
createEffect(() => {
thumbMount()
updateThumb()
})
// Inputs that change the thumb without resizing anything: re-measure on change, but leave the
// first measurement to the observer.
createEffect(
on([() => local.verticalScrollAdjustment, vertical, horizontal, thumbMount], () => updateThumb(), {
defer: true,
}),
)
createEffect(() => {
if (!horizontal() || !viewportRef) return