Skip to content

Fix YouTube Home load (stuck skeleton, latency) + floating window drag/crash#308

Open
sozercan wants to merge 14 commits into
mainfrom
fix/youtube-home-load-and-window-fixes
Open

Fix YouTube Home load (stuck skeleton, latency) + floating window drag/crash#308
sozercan wants to merge 14 commits into
mainfrom
fix/youtube-home-load-and-window-fixes

Conversation

@sozercan

Copy link
Copy Markdown
Owner

Summary

A batch of YouTube-mode fixes, mostly on the Home feed, plus the pop-out video window. Each fix was verified by tests and — for the runtime/timing bugs — by live measurement on a cold launch.

Home feed

  • Stuck skeleton on cold launch (loaded only after navigating away/back). SwiftUI restarts the load .task twice ~18 ms apart on first paint; the restart cancelled load chore(deps): bump actions/checkout from 4.2.2 to 6.0.1 in the github-actions group #1, load chore(deps): bump the github-actions group across 1 directory with 2 updates #2 bailed on the idle-guard, and chore(deps): bump actions/checkout from 4.2.2 to 6.0.1 in the github-actions group #1's cancellation reset state to .idle with nothing running. Fixed by making load() single-flight via a stored unstructured Task that survives .task cancellation; concurrent callers coalesce onto it.
  • Rows snapped in ~840 ms after the grid. All 8 topic rails published atomically after the slowest (~770 ms) even though fast rails finished at ~300 ms. Now each rail streams into sections as it resolves, slotted in chip order — first row ~480 ms sooner, progressive fill instead of a snap.
  • Slow time-to-first-grid. The 2 MB FEwhat_to_watch response was fetched + walked up to 3×; coalesced into one getHomeBundle() that parses off the main actor. Cold-launch caching was disabled until the account identity resolved (forcing a second serial 2 MB fetch on the critical path) — scoped to a stable pending identity, cache-before-auth, and topic rails are now cached.
  • Thumbnails decoded ~4× oversized and serially; right-sized, made the decode nonisolated (parallel), and prefetch the first screenful.
  • The bare .task { load() } on Home/Shorts/Subscriptions/History/Playlists didn't re-fire when an account swap rebuilds the view model — keyed on ObjectIdentifier(viewModel).

Floating video window

  • Hard to drag: the corner-to-corner WKWebView defeats isMovableByWindowBackground; added a native top drag strip driving NSWindow.performDrag.
  • SIGABRT when resized very small: contentAspectRatio + contentMinSize were competing constraints; replaced with a single windowWillResize clamp.

Docs

  • Captured the measurement-first debugging lesson and the sandbox logging gotcha (os_log//tmp writes fail silently → use NSTemporaryDirectory() file traces) in AGENTS.md + docs/common-bug-patterns.md.

Test plan

  • swift build clean
  • swiftformat . and swiftlint --strict clean
  • swift test --skip KasetUITests1428 tests pass (added: single-flight survival, refresh restart, incremental ordered publish, raw-Data cache round-trip, single coalesced home fetch)
  • Cold-launch measured live: stuck-skeleton gone (load completes on first paint); first row ~358 ms after grid vs ~840 ms before
  • Reviewer: cold-launch YouTube Home and confirm rows fade in shortly after the grid (no snap); shrink the pop-out window to the corner (no crash) and drag it from the top

sozercan added 6 commits June 18, 2026 14:25
The pop-out video window was nearly impossible to move: its content is a
corner-to-corner WKWebView that reports mouseDownCanMoveWindow == false and
swallows mouseDown, defeating the window's isMovableByWindowBackground. Add a
native top drag strip that drives NSWindow.performDrag explicitly (double-click
still zooms).

Also fix a SIGABRT when the window is resized very small: contentAspectRatio +
contentMinSize are competing constraints, so AppKit let one axis slip below the
floor and the macOS 26 NSHostingView safe-area corner-inset update raised an
uncaught NSException in the display-cycle commit. Replace them with a single
windowWillResize clamp (NSWindowDelegate) that floors and 16:9-snaps every
proposed frame, and tighten observer cleanup.
…t cache

Measured cold-launch time-to-first-grid was dominated by redundant work and a
cache miss:

- The 2 MB FEwhat_to_watch response was fetched and walked up to 3x (feed,
  chips, shelves). Coalesce into one getHomeBundle() that fetches once and
  parses off the main actor (raw Data -> value types), so deserialize + the
  recursive walk no longer block first paint.
- On cold launch the account identity isn't loaded yet, so nothing was cached
  and getHomeShelves became a second serial 2 MB fetch on the critical path.
  Scope the cache to a stable "pending" identity until the real one resolves
  (invalidated on account switch, so no cross-account leakage), and check the
  cache before building auth headers.
- Cache topic-rail browses (getHomeTopicFeed) so returning to Home is instant.

Adds a raw-Data cache path (getData/setData) reusing the existing TTL/LRU store.
VideoCard requested a 640x360 target that ImageCache doubled to a 1280px decode
for a <=320pt card — ~4x the pixels, thrashing the 50 MB memory cache. Right-size
to 320x180. Make the disk read + ImageIO downsample nonisolated so warm-cache
decodes parallelize across cores instead of serializing on the ImageCache actor,
and prefetch the first screenful of thumbnails when the grid publishes.
Two cold-launch bugs in the Home view model, both diagnosed from a timestamped
file trace (the app is sandboxed, so os_log streaming was unreliable):

- Stuck on the skeleton until you navigated away and back. SwiftUI restarts the
  load .task twice ~18 ms apart on first paint; the restart cancelled load #1,
  load #2 bailed on the idle-guard, and #1's cancellation reset to .idle with
  nothing running. Make load() single-flight via a stored unstructured Task so
  the work survives .task cancellation and concurrent callers coalesce onto it.

- The grid appeared ~840 ms before the side-scrolling rows, then snapped. All 8
  topic rails were published atomically after the slowest one (~770 ms), even
  though fast rails finished at ~300 ms. Stream each rail into `sections` as it
  resolves, slotted in chip order, so rows appear ~480 ms sooner and fill in
  progressively. (A first attempt that revealed a strict in-order prefix was
  caught by re-measuring — it just gated on the slow first chip.)

Replaces the old cancel-aborts-the-load test (it encoded the bug) with tests for
single-flight survival, refresh() restart, and incremental ordered publish.
The YouTube detail views used a bare .task { load() }, which does not re-fire
when the account swap rebuilds the view model (a fresh, idle instance) — so the
tab could sit unloaded until a navigation changed view identity. Key the load
task on ObjectIdentifier(viewModel) (the pattern YouTubeExploreView already
uses) on Home, Shorts, Subscriptions, History, and Playlists.

Home also fades the side-scrolling rails in (and eases the grid down) as they
stream in, so the progressive fill reads as intentional motion.
Add a Debugging & Measurement section to AGENTS.md (measure before fixing
timing/lifecycle bugs; the app is sandboxed so os_log and /tmp writes fail
silently — use NSTemporaryDirectory file traces) and two new common-bug-patterns
entries: the sandbox-safe trace template and the .task-restart cancellation
deadlock with the single-flight load fix.
Copilot AI review requested due to automatic review settings June 18, 2026 21:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afc79fcbcb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Views/YouTube/VideoCard.swift
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated
Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR is a batch of YouTube-mode fixes centered on the Home feed cold-launch experience and the pop-out video window. On Home, it replaces the idle-guarded load() with a single-flight unstructured Task that survives SwiftUI .task cancellation (fixing the stuck-skeleton bug), coalesces the home feed/chips/shelves into one off-main-actor getHomeBundle() fetch+parse, streams topic rails into sections as each resolves (instead of one atomic snap), enables cold-launch caching under a stable "pending" scope, and right-sizes/parallelizes thumbnail decodes. For the floating video window, it swaps the conflicting contentAspectRatio+contentMinSize pair for a single windowWillResize clamp delegate (fixing the SIGABRT on tiny resizes) and adds a native top drag strip. It also documents a measurement-first debugging workflow.

Changes:

  • Home load reliability + perceived latency: single-flight load, coalesced getHomeBundle() parsed off-main, incremental rail streaming, cache-before-auth + pending cache scope, thumbnail right-sizing/prefetch, .task(id:) re-keying across YouTube views.
  • Floating window: YouTubeVideoWindowResizeGuard delegate (16:9 + floor clamp) replacing competing constraints, plus a WindowDragHandle driving NSWindow.performDrag; fullscreen observers now torn down in cleanup.
  • Docs: measurement-first / sandbox-logging guidance in AGENTS.md and docs/common-bug-patterns.md.
Show a summary per file
File Description
Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Single-flight load(), streamTopicRails, refresh() cancel; latent loadTask clear race noted.
Sources/Kaset/Services/API/YouTubeClient.swift getHomeBundle/requestData/performRequestData, cache-before-auth, pending scope helper.
Sources/Kaset/Services/API/Parsers/YouTube/YouTubeFeedParser.swift parseHomeBundle(from: Data) one-pass parse; lacks a direct test.
Sources/Kaset/Services/API/APICache.swift getData/setData raw-bytes boxing reusing TTL/LRU machinery.
Sources/Kaset/Services/YouTubeProtocols.swift Adds getHomeBundle() to the protocol.
Sources/Kaset/Services/API/MockUITestYouTubeClient.swift UI-test mock getHomeBundle.
Sources/Kaset/Models/YouTube/YouTubeFeed.swift New YouTubeHomeBundle value type.
Sources/Kaset/Utilities/ImageCache.swift Off-actor disk decode via nonisolated helpers + detached task.
Sources/Kaset/Views/YouTube/VideoCard.swift Thumbnail decode target 640×360 → 320×180.
Sources/Kaset/Views/YouTube/YouTubeHomeView.swift Rail-insertion animation, thumbnail prefetch task, .task(id:) keying.
Sources/Kaset/Views/YouTube/YouTube{Shorts,Subscriptions,History,Playlists}View.swift .task(id: ObjectIdentifier(viewModel)) re-keying.
Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift Resize-guard delegate, drag handle, fullscreen-observer teardown.
Tests/KasetTests/* New view-model, mock, and APICache raw-data tests.
AGENTS.md, docs/common-bug-patterns.md Measurement-first debugging + single-flight pattern docs.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 20/20 changed files
  • Comments generated: 2

Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated
Comment thread Sources/Kaset/Services/API/Parsers/YouTube/YouTubeFeedParser.swift
- Restore auth validation before serving a cached personalized YouTube response
  (request + requestData): a cleared/expired session must not keep rendering
  private cached data until the entry expires.
- Key the image memory cache by URL *and* target size so a Home 320x180 decode
  no longer satisfies the watch view's 1280x720 request (which left it blurry).
  Disk still holds URL-keyed raw bytes, so sizes share one download.
- Single-flight load: token-gate `defer { loadTask = nil }` so a stale run
  resuming late cannot null a newer run's handle, and cancel the outgoing Home
  model's load in resetForAccountChange so a discarded model stops using the
  shared client after the cache scope moved to the new account.
- Add direct parseHomeBundle(from:) tests (success + non-JSON throws) and a
  single-flight regression test for the stale-resume race.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d19e4768f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Utilities/ImageCache.swift
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift
Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated
…story split

Follow-ups from the review of the previous fix commit:

- Cache the raw home bytes only AFTER a successful parse (and skip the write on
  cancellation), so a transient non-JSON 200 can't poison the 5-minute Home
  cache and make retries fail without re-fetching.
- Check cancellation before mutating `homeContinuation`: the detached bundle
  parse isn't cancelled when the Home model is discarded, so an account switch
  must not let the stale task overwrite the new account's continuation.
- Key the image in-flight table by URL + size too (not just the memory cache),
  so a small in-flight decode isn't handed back to a larger request.
- Invalidate the API cache on signOut/sessionExpired, so the account-unknown
  "pending" cache scope can't serve the previous user's data after a re-login.
- Start the topic rails without awaiting watch history: history runs as one more
  concurrent rail in the streamer (the single writer), Continue Watching slots
  in at the front when it lands, so a slow history call no longer delays the
  rows or keeps an empty grid on the skeleton.

Adds tests for the rail/history split and the new mock history gate.
Copilot AI review requested due to automatic review settings June 18, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 23/23 changed files
  • Comments generated: 2

Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated
Comment thread Sources/Kaset/Utilities/ImageCache.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c445f3d0f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Views/YouTube/YouTubeShortsView.swift
Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated
sozercan added 2 commits June 18, 2026 15:35
- Register the image in-flight task BEFORE the off-actor disk read. The detached
  loadFromDisk introduced an await between the memory miss and inFlight
  registration, so two concurrent cold requests for the same URL+size (e.g. the
  first-screen prefetch and a visible card) could both miss and each start a
  separate download. The whole disk-then-network path now runs inside one
  registered task so concurrent callers coalesce; the disk decode still runs
  detached for cross-core parallelism.
- Fix a botched streamTopicRails doc comment (dangling "Measured…" fragment plus
  a duplicated summary line).
- Don't clear the Home skeleton on an empty publish. The first rail result is
  often history returning nil (no resumable watch history); with an empty grid
  and no shelves, flipping `.loaded` then flashed the "No recommendations" state
  before the topic rows arrived. Only clear when there is real content; the
  genuinely-empty case is still handled by the terminal `.loaded`.
- Reset Shorts `currentShortId` when the view-model identity changes, so an
  account swap autoplays the new model's first short instead of leaving a stale,
  not-in-pager ID selected.
- Capture the Home cache key before the network await (the authenticated scope)
  and only write if it still matches afterwards, so a sign-out mid-flight can't
  store the previous user's bytes under the account-unknown `pending` scope.

Adds an empty-grid-no-flash regression test.
Copilot AI review requested due to automatic review settings June 18, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 23/23 changed files
  • Comments generated: 1

Comment thread Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83c641c069

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift
Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated
Comment thread Sources/Kaset/ViewModels/YouTube/YouTubeHomeViewModel.swift Outdated
… loadingMore

- Restore the shared FEwhat_to_watch cache between Home and Shorts. getHomeBundle
  cached raw bytes under yt:data:browse while getShorts still read yt:browse, so
  navigating between them re-fetched the 2 MB response. Both now go through one
  homeResponseData() that reads/writes the single shared key.
- Honor height-driven window resizes: normalizedContentSize takes the current
  size and follows whichever axis the user drags, so a vertical-edge drag no
  longer snaps back to the old width-derived 16:9 height.
- Honor the user's "double-click a window's title bar to" setting (Zoom /
  Minimize / None) instead of always zooming; the comment now matches behavior.
- Preserve `.loadingMore` during rail updates: the skeleton-clear and terminal
  publish now only flip from `.loading`, so a concurrent loadMore() on an
  empty-grid first page can't be reset to `.loaded` and start a duplicate
  continuation.

Adds normalizedContentSize tests (width/height-driven + floor).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2eac1a281a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated
The round-5 resize tests recomputed the expected value via the same float
expression as the impl ((700*16/9).rounded()), which compared as unequal at the
sub-ULP level on the CI runners (passed locally). Assert against the exact
rounded integer literals (1244, 563) instead — matching the style of the
width-driven test that passed.
Copilot AI review requested due to automatic review settings June 18, 2026 23:20
…eneration

The prior fix compared only the cache-scope key before/after the fetch, which
can't catch a sign-out during an account-unknown load: the key stays `pending`
across a nil->nil account transition, so the previous user's FEwhat_to_watch
bytes were still written back under `pending` (readable after a re-login).

APICache now exposes a monotonic `generation` bumped on every `invalidateAll()`
(account switch / sign-out / session expiry). homeResponseData captures it before
the network await and writes only if BOTH the scope key and the generation are
unchanged, so a sign-out mid-flight rejects the stale write.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 24/24 changed files
  • Comments generated: 1

Comment thread Sources/Kaset/Services/API/YouTubeClient.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8fa3cd6e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Kaset/Services/API/YouTubeClient.swift
…on guard

- Keep the home parse off the main actor. The prior revision validated the 2 MB
  response with JSONSerialization on the @mainactor client (cold path) and then
  parsed it again off-main — a main-thread 2 MB deserialize before first paint
  plus a redundant second parse, undercutting this PR's core goal. The off-main
  parseHomeBundle (which already throws on non-JSON) now drives caching: fetch ->
  parse off-main -> cache the raw bytes only after a successful parse. Home and
  Shorts share one homeBundle() loader.
- Extend the cache-generation guard to request(): getHistory and the topic-rail
  browses cache through request(), which wrote results after its await without
  the generation check. A sign-out mid-flight (pending->pending, key unchanged)
  could store the previous user's history/topic data. Capture the generation
  before the await and only write if it's unchanged.
Copilot AI review requested due to automatic review settings June 18, 2026 23:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 24/24 changed files
  • Comments generated: 0 new

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