Skip to content

Perf improvements#205

Draft
aJanuary wants to merge 28 commits into
lostcarpark:mainfrom
aJanuary:perf-improvements
Draft

Perf improvements#205
aJanuary wants to merge 28 commits into
lostcarpark:mainfrom
aJanuary:perf-improvements

Conversation

@aJanuary

Copy link
Copy Markdown
Collaborator

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:

  • Better memoization. By tracing through how data is used, and being more careful about what data we pass through we can allow React to be more efficient about what it mounts and unmounts and what work needs to be done in a render cycle.
  • Progressive loading. Rather than loading the entire schedule in one go, which requires a lot of mounting, load them in chunks. The first chunk of 10 is done synchronously to get something on screen early, and then the rest of delegated to a lower priority task using React transitions. This allows the UI to remain responsive while the remaining chunks of 100 are loaded in.
  • Wrap the search in a deferred value
  • Load the filters early. While the contents of the dropdowns depend on processing the received JSON data, we know from page load (because of the config.json) what filters we are likely to have. This lets us show them on page load (just disabled), reducing the amount of visual disruption that happens when the content is loaded.
  • Defer the loading of the spring animations. The spring animations are quite memory intensive, so rather than eagerly loading them for every item, defer the loading of them to when the user first expands the item. To ensure it is ready in time for the animation, we "warm" it by loading it on pointer down or focus.

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.

@aJanuary
aJanuary requested a review from lostcarpark July 11, 2026 10:43
@aJanuary

Copy link
Copy Markdown
Collaborator Author

Currently in draft status while I do more testing and try to find people with underpowered devices to try it out.

aJanuary and others added 28 commits July 13, 2026 19:29
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>
@aJanuary
aJanuary force-pushed the perf-improvements branch from fb014b1 to 9391f2f Compare July 15, 2026 07:24
@lostcarpark

Copy link
Copy Markdown
Owner

This is looking amazing so far.
I never noticed how many timezones react-timezone-select is missing, but it does keep the list manageable. It looks like it only shows one timezone per timezone period (there's probably a better way to describe that).
There are a couple of issues with the new display. The biggest is it orders -01 to -11 then +00 to +14 (I presume it's just ordering by the text content) - it should definitely start from -11.
The other is the sheer number of timezones included. It would be good to allow a text search, so you could type "Dub" and be shown "Europe/Dublin", and any other timezones that include the text.
I think that's mostly beyond the scope of this PR, and could be handled as a separate change, but I would like to see the sort order corrected to match the previous functionality.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants