Perf improvements#205
Conversation
|
Currently in draft status while I do more testing and try to find people with underpowered devices to try it out. |
Sanitizing item.desc on every render was wasted work once an item was already mounted, since the description never changes after initial load.
applyFilters ran a fresh linear pass over the whole programme on every render with no memoization. Wrapping it in useMemo requires accounting for its hidden wall-clock dependency: LocalTime.filterPastItems and isDuringCon each read Temporal.Now directly, so the memo couldn't otherwise tell when "past" had shifted enough to change the result. Both functions now take `now` as a required parameter instead of defaulting to the real time internally, so that dependency is visible at every call site rather than hidden. That ripples out to every consumer of "hide past items" logic (ShowPastItems, MySchedule, UnfilterableProgram) and to ProgramList's own relative-time display, all of which now need a `now` value to pass in. A new useTickingNow() hook centralizes the actual 10-second timer so each of those call sites gets a consistent value without duplicating timer setup, rather than one component owning it and others falling back to their own.
A Firefox Profiler capture showed ~61% of the render window on initial load was a single browser GCMajor pause, not layout/paint/JS execution - allocation pressure from mounting ~1000 ProgramItems at once, each setting up a ResizeObserver (useMeasure) and a react-spring Controller regardless of whether the item is ever opened. - New ExpandableDetails.jsx owns the expand/collapse height animation, lazily mounted only once an item is actually interacted with (a pointerdown/focus pre-warm keeps the first-ever expand animated rather than snapping, since the spring needs to exist with a collapsed baseline before the real transition). - The chevron's rotation moved from a second react-spring Controller to a plain CSS transition (App.css): rotation is always a fixed known value (0deg/180deg), unlike height, which depends on runtime content and genuinely needs measuring - so it doesn't need JS animation at all, and no longer needs to be lazily mounted either. - ProgramItem wraps in React.memo with a custom comparator: the ticking `now` prop is compared via its derived before/during/after bucket rather than by reference, so a 10s tick only re-renders items whose bucket actually changed instead of all ~1000. - TimeSlot/Day wrap in plain React.memo.
addProgramParticipantDetails did a linear people.find() per participant per programme item - roughly items * participants * people comparisons. Building an id -> person Map once turns each lookup into O(1).
Mounting ~1000 ProgramItems in one synchronous commit blocks the page for the full duration. Revealing them in chunks (visibleCount growing by CHUNK_SIZE, each step inside startTransition) marks the mount as low-priority interruptible work: user input can pre-empt an in-flight chunk instead of queueing behind it, so the page becomes interactive after the first chunk instead of after all of them. Chunks chain off the previous chunk's transition settling (isPending flipping false) rather than a fixed clock, so a slow chunk delays the next one instead of piling up. Nothing is unmounted once revealed - this only spreads the initial mount cost over time. A beforeprint listener forces the full list to reveal immediately - deliberately not a transition, so it pre-empts the in-flight reveal and printing always gets the complete programme. The programme is grouped into days once per data change rather than per render, so each fully-revealed day keeps a stable items array and memo(Day) can skip it; only the day at the reveal frontier gets a fresh slice as the budget grows. This keeps each chunk's render cost proportional to the chunk, not to everything mounted so far, and the memoized list as a whole stops unrelated store updates (expanding an item, changing selections) from rebuilding the page's most expensive subtree. The reveal only ever grows or clamps down to fit the current list - it never restarts from scratch just because `program`'s reference changed. The clamp isn't for visual safety (the reveal budget caps naturally) but to reset the growth budget: without it, widening a filter back out would mount every newly-matching item in one synchronous commit. Resetting unconditionally instead only caused a fully-revealed list to visibly flicker back down and replay the climb on every filter/search keystroke.
FilterableProgram used to be gated behind the generic loading spinner like every other route, so the filter bar (location/tag selects, search box, expand/collapse buttons) didn't mount until data had fully loaded. It now manages its own loading state: the filter bar, the convention-time notice, and a placeholder for the list all render immediately, with controls disabled and dropdowns showing their config-defined categories (still unpopulated) until data arrives. - Extracted Loading.jsx's error branch into LoadError.jsx so both the per-route Loading wrapper (still used for other routes) and FilterableProgram can show the same error state. - TagSelectors/TagSelect derive the loading-time category list from static config (tagConfig.SEPARATE + DAY_TAG), in the same order ProgramData.processTags actually builds them in, so the skeleton dropdowns match what appears once real data loads.
bufferedStartDateAndTime/bufferedEndDateAndTime were only ever used via Temporal.ZonedDateTime.compare() (never formatted, never timezone-aware) - two full ZonedDateTime objects (each backed by a BigInt) constructed per item purely for comparison. Storing them as epoch milliseconds instead gives identical comparison semantics at a fraction of the allocation cost, for both the one-time data processing pass and every render's getRelativeTime() call.
showLocalTime, show12HourTime and timeZoneIsShown are all global values, not per-item, but ProgramItem subscribed to each of them independently (~1000 redundant easy-peasy subscriptions on every mount). TimeSlot already subscribed to the display settings for its own header. It now passes all four down as props, cutting ProgramItem's store subscriptions from 6 to 2 (the genuinely per-item ones: selected, expanded). The React.memo comparator now also checks these props so ettings changes and bulk expand/collapse still correctly reach every item.
useStoreActions(actions => ({ a: actions.a, b: actions.b })) allocates
a fresh wrapper object every render even though the action functions
themselves are stable. Selecting each action individually avoids that
allocation, multiplied by ~1000 items.
Gives the progressive-mount reveal a visible "still loading, making progress" signal instead of rows just popping into existence in batches. Respects prefers-reduced-motion.
Because we know that nothing inside of an item can affect the layout of anything outside of the item, this hint allows the browser to reduce the amount of layout work it needs to do, improving performance.
A background refresh commonly comes back byte-identical to what's already loaded, since the schedule rarely changes within a given 30-minute window. Previously, every refresh unconditionally reprocessed and replaced program/people/etc. with fresh array references even when nothing changed, which made ProgramList's progressive-mount reveal treat it as a new list and restart - visibly snapping a fully-revealed page back to a partial reveal for no reason. ProgramData.fetchData now hashes the raw fetched text (SHA-256, via the fingerprint helper) and compares it against the caller-supplied previousFingerprint, skipping the per-item processing (DOMPurify sanitization, Temporal conversions, sorting, tag/location extraction) and returning data: null when unchanged. The fingerprint is a fixed-size digest rather than the raw text itself, so a large schedule/people payload isn't held in memory indefinitely just to compare against. fetchData takes the previous fingerprint as a parameter and returns the new one rather than tracking it internally, so it stays deterministic given its inputs - no hidden state to reset between calls. The store (model.js) owns lastFetchFingerprint as ordinary state and passes it through fetchProgram, consistent with how the rest of the app's state is managed.
The progressive reveal now handles large lists smoothly, so paging through results with a manual button no longer serves a purpose, and neither does capping how many items are displayed. Removes the "Show more" button, the limit drop-down, and the whole PROGRAM.LIMIT config block along with its README documentation.
A small spinner and message at the bottom of the list while the progressive reveal is still mounting chunks, so the filling-in list reads as deliberate loading rather than missing content. Keyed on the remaining count rather than the transition's isPending, which briefly flips false at every chunk boundary and would flicker the indicator. The message is configurable via PROGRAM.LOADING_MORE_MESSAGE; the spinner respects prefers-reduced-motion.
Transitions keep each reveal chunk's rendering interruptible, but the commit that mounts a chunk (DOM insertion + layout) is atomic, and at ~100 items it is long enough to eat frames from a short expand/collapse animation. Rather than competing for frames, the reveal now yields outright: any pointerdown or keydown defers the next chunk until 300ms after the last interaction, so interaction-triggered animations get every frame to themselves. A chunk already in flight when the interaction starts still lands - a queued transition commit can't be recalled.
The per-timeslot format caches are arrays indexed by slot number, but the lookup used indexOf(timeSlot), which searches the array's *values* for the slot number. The values are cache-entry objects, so the search always came back empty and every call re-ran the Temporal timezone conversion and format - for every item, on every render that reached it. Look up by index instead.
This means we don't have to apply the filter to every keystroke, improving the responsiveness of the search.
Grouping the programme list into days did a timezone-aware Temporal round() per item inside the grouping memo, so every new filtered list - e.g. each search keystroke's deferred render - paid ~1000 rounding operations before anything could mount. The convention day of an item never changes after load (day grouping is pinned to the configured convention timezone; only the *local* time display is user-selectable), so stamp a dayKey string on each item in processProgramData and group by comparing those. Day headings format directly from the key string - Temporal.PlainDate.from() accepts the zoned ISO form.
Expand/collapse all triggered ~1000 simultaneous react-spring height animations, each writing an inline height per frame and forcing layout - main-thread work nobody can follow visually. Bulk actions now record themselves as bulk in the store, and items apply their height change immediately (react-spring's immediate flag) while individual toggles keep animating. Items read the bulk flag non-reactively (getState at render) - an item only needs it when its own expanded state just changed, which already re-rendered it, and subscribing would re-render every item whenever the flag flipped.
LocationProgramme dispatched setProgramSelectedLocations during render with fresh option objects on every pass; each store update re-rendered its subscribers, which re-rendered LocationProgramme, which dispatched again - an endless per-frame loop. It surfaced as multi-second item mounts and scroll position jumping to the top on /loc/ pages, plus React's setState-during-render warnings. Sync the URL params into the store in an effect keyed on the param instead.
Mounting the full programme, even progressively, leaves underpowered devices with a huge DOM and a sluggish UI. Instead, gate the chunked reveal on scroll position: an IntersectionObserver sentinel below the mount frontier lets chunks mount only until the list is one viewport ahead of the fold, so the DOM grows as the user scrolls. A filter change resets to the first page (a suffix check keeps the ticking past-items trim from resetting mid-browse), and printing still mounts everything. Chunk size doubles as the page size and adapts to the device: each uninterrupted full chunk's settle time steers the next size towards a 200ms budget (clamped to 25-400, at most halving/doubling per step). The learned size is module-level, so it survives navigation and is shared by every ProgramList view. This also restores the PROGRAM.LIMIT config block, display-limit drop-down, and "Show more" button removed in e6e738d as an opt-out: PROGRAM.LIMIT.INFINITE_SCROLL (default true, along with INITIAL_PAGE_SIZE) silently overrides them when enabled. The restored per-filter resetDisplayLimit call sites collapse into one effect keyed on the filter inputs.
`.item ~ .item` makes every infinite-scroll chunk append re-match styles for all later items; `:not(:first-child)` is equivalent but append-stable. Adding `style` containment narrows invalidation further.
The persisted time-format caches saved ~34ms of arithmetic per visit but cost more than that in bookkeeping (a polyfill toString() per item per data load in getTimeSlot) and carried staleness and quota risks. Removing the persistence surfaced a display bug: getTimeSlot pushed the same entry object into both the convention and local caches, and the formatters share cache keys (h12_no etc.), so on a fresh session whichever formatter ran first poisoned the other - local times rendered as convention times. The caches now get separate objects, and the slot cache is keyed by epochMilliseconds instead of object-coerced strings. ProgramList also no longer JSON.stringifies three caches to localStorage after every render.
DOMPurify.sanitize ran at every row's first render, but the result is only used by ExpandableDetails, which is deliberately not mounted until expansion is imminent. Moving the sanitize there means collapsed rows (the vast majority of ~1000) never pay for it.
Every mounted ProgramItem consults isSelected and isExpanded on each store change, and Array.find per row goes quadratic once many items are expanded (~450k comparisons per store change with all 957 items expanded). State-resolver-backed Set computeds make each lookup O(1) and rebuild only when the underlying array actually changes.
useTickingNow published a fresh Temporal object every 10 seconds, reconciling all ~957 mounted items to recompute identical answers - a ~100ms main-thread stall per tick on a throttled desktop, forever, since it fired even outside the convention. Profiles showed it as the largest steady-state cost on a fully-mounted list. Time behavior now lives in utils/ProgramTime as a single contract: boundariesOf(item) derives every epoch a decision compares the clock against; the frozen reading from programTimeAt(epochMs, program) exposes only query methods (phaseOf, isPast, isDuringCon, hidePastItems), so components cannot compare the clock against anything the gating doesn't know about; and collectBoundaries feeds useProgramTime, which checks the clock every 10s but only publishes a new reading when a boundary was crossed or the programme was replaced. Reading identity thereby doubles as the memo key for the whole tree. The tri-plicated during-con/show-past-items guard collapses into the reading's hidePastItems, and filterPastItems' per-item Temporal arithmetic becomes one precomputed epoch comparison. Verified: zero idle long tasks and zero idle commits outside the con (previously one full reconcile per tick); mid-con (mocked clock), readings publish only on boundary crossings and past items disappear as time advances.
react-timezone-select pulled in two bundled timezone databases
(spacetime, timezone-soft) - ~125kB minified, 14% of the vendor chunk -
to render one dropdown in Settings. The browser already has the data:
Intl.supportedValuesOf("timeZone") for the zone list and
Intl.DateTimeFormat for offset labels, presented through the same
react-select styling.
Both Intl features are guarded: engines without supportedValuesOf
(pre-Safari 15.4) get a picker offering just the browser's own zone,
and longOffset failures fall back to bare zone names - degraded, never
crashing, so the app's compatibility floor is unchanged.
The programme list is what slow devices open the app for, but the eager bundle carried the whole markdown-rendering ecosystem (~155kB minified, used only by the info page and three one-line footer strings) and the QR generator (~24kB, my-schedule sharing only), and startup fetched info.md alongside the schedule. - Info and ShareLink are React.lazy route-level chunks. - The footer's markdown comes from config.json, so a Vite plugin renders it to HTML at build time (micromark, build-time only) - the footer ships no markdown code at all. - info.md is fetched by a fetchInfo thunk on first visit to /info, and drops out of the programme fetch and its change-detection fingerprint. - The forced single vendor chunk in vite.config is removed: it would pull lazily-imported dependencies back into the eager bundle, defeating the splits. info.md is no longer part of the 30-minute background refresh, so a mid-con edit to it won't reach an already-open session that revisits /info without reloading (state.info is cached for the session). A page reload always refetches it, with the same HTTP caching semantics as schedule.json. Also fixes a latent bug this surfaced: the previously-unused setInfo action implicitly returned the assigned string, which immer treats as a replacement for the entire state tree. Eager JS drops from 993kB to 690kB minified (323kB to 221kB gzip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fb014b1 to
9391f2f
Compare
|
This is looking amazing so far. |
Misc performance improvements, based on analysis of profiles generated by both Firefox and Chrome desktop.
This takes the initial load of a Worldcon schedule from 1.5 seconds to 0.4 seconds on my computer, and removes any lag with the filters, especially when typing in the search box. Less obvious is the general reduction in the amount of garbage collection that needs to happen.
The main categories for the improvements:
An open question is how this should interact with the current "LIMITS" feature. My understanding is that this was introduced to avoid the cost of loading the whole schedule when the schedule is large and/or the device is underpowered. The hope is that these performance improvements mean that feature is no longer required. However, if needed we can bring it back and figure out how to make it interact with the progressive loading.