Skip to content

Keep views mounted across tab switches for instant navigation - #80

Merged
molefrog merged 2 commits into
mainfrom
claude/view-switching-animation-tom5e3
Aug 6, 2026
Merged

Keep views mounted across tab switches for instant navigation#80
molefrog merged 2 commits into
mainfrom
claude/view-switching-animation-tom5e3

Conversation

@molefrog

@molefrog molefrog commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Switching between view tabs no longer animates — it swaps instantly, and a view you come back to is the one you left, state intact. A view loads once: the first open shows a centered splash, every later switch is immediate. Recently-visited views stay mounted but hidden (React's <Activity>) for 60s and are then disposed; closing the workspace disposes them all. The one animation left is a rebuild: when the view you are looking at is rebuilt, the new build dissolves in over the old one (200ms, 4px blur).

The bug behind this: the crossfade kept the outgoing view's DOM alive past the moment React unmounted the applet's scoped <style>, so the view spent its exit unstyled. Keeping views mounted removes the problem instead of timing around it.

Verified in Chromium against a scratch workspace with three views:

step result
first open of beta splash rendered, then the view — no animation
beta → alpha (parked) same component instance, counter still 2, display: noneblock
both parked (agent tab) both style[data-applet-style] tags still mounted, applet colors still computed, chat composer clickable
after 65s parked slots and style tags gone; reopening loads fresh
rebuild while on screen 9 overlap frames — incoming starts at opacity 0 / blur(4px) over the old build (no blank frame), reaches 1 / blur ~0 at 186ms, old build dropped at 201ms

Worth a close look:

  • ViewSlot acquires the applet's <style> above the <Activity> boundary. A hidden Activity unmounts its children's effects, so acquiring it inside would reintroduce the exact tear this PR removes. It is also what lets the dissolve overlap two builds: the sheet belongs to the slot, not to either frame.
  • <Activity> hides with display: none: a parked view keeps its React state but loses scroll position, and its effects unmount and re-run on return. Called out in docs/moi-views.md for view authors.
  • The active view raises its <style> to the end of <head>. Applet CSS is container-scoped, but global at-rule names (@keyframes, @property) are not, so several mounted views share one namespace — this makes the view on screen the winner. See the Codex thread: namespacing them properly belongs in scopeAppletCss.

Verification: bun test — 919 pass, 0 fail (15 new in view-residency.test.ts, covering the retention window, the LRU cap, and deleted-view pruning); bun run lint, format:check, and typecheck:client clean; the browser matrix above, plus a widget-grid check that the shared AppletMount path still mounts and styles widgets.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PaXfVTPUsTUpLkFhjFNU3y

Switching views crossfaded the outgoing view out, but the applet's scoped
<style> is unmounted with its component — so the view spent its exit
unstyled. Rather than time the styles against the animation, views now stay
mounted: ViewManager keeps the active view visible and recently-visited ones
parked offscreen behind React's <Activity>, so switching is instant and a
view loads once.

- First open shows a centered splash (delayed, so a fast load never flashes
  it); every later switch is immediate and keeps the view's state.
- Parked views keep their DOM and their styles; the style tag is acquired
  above the Activity boundary, which unmounts its children's effects.
- Residency policy (retention window, LRU cap, deleted-view pruning) is pure
  and tested in view-residency.ts; the workspace unmount disposes everything.
- A rebuild swaps the view in place, keeping the previous build on screen
  until the new one is ready, so agent edits no longer flash a spinner.

AppletMount loses `asChild` (only the view path used it) and shares the
`data-applet` scope helper with the view slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaXfVTPUsTUpLkFhjFNU3y

@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: ee04add0bc

ℹ️ 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 client/features/views/ViewManager.tsx Outdated
// an Activity unmounts its children's effects, which would strip the styles
// off the page while the DOM they style is still parked — the exact flash
// this component exists to remove. The tag drops when the slot is evicted.
useAppletStyle(appletStyleKey('views', workspaceId, view.id), loaded?.version ?? bundle.version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid retaining globally named CSS from parked views

When two resident applets define the same @keyframes name, this keeps both style tags active even though the CSS scoper deliberately leaves keyframes unnamespaced (server/bundler/applet-css.ts:16-17). After visiting view A, then B, and returning to A within the retention window, B's later tag remains in <head>, so animations in A can resolve to B's keyframe definition; parked widgets can cause the same collision. Namespace global CSS at-rules per applet or reacquire only the active applet's stylesheet before revealing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed the mechanism — scopeAppletCss skips rules inside @keyframes because step selectors aren't element selectors, so the at-rule name stays document-global and the last definition wins for every applet.

Two corrections to the framing, then what I changed in 283a34b:

  • It isn't new here. The widget grid already mounts every visible widget at once, each with its own tag, so two widgets with clashing custom keyframes collide today and did before this PR. What this PR adds is a second view sheet during the retention window.
  • Retaining the parked sheet is load-bearing, so dropping it isn't an option: the parked DOM is revealed by flipping <Activity> back to visible, and re-acquiring at that point is precisely the styles-arrive-late tear this PR exists to remove.

So instead: useAppletStyle now takes onTop, and the active view raises its <style> to the end of <head>. Everything else in an applet sheet is container-scoped and can't collide; only the global at-rule names can, and now they resolve to the view actually on screen. Verified in Chromium — sheet order is [alpha, beta] with beta active and [beta, alpha] after switching back to alpha.

What this deliberately does not do is namespace the at-rules, which is the real fix and belongs in scopeAppletCss (with server/test/applet-css.test.ts covering it), not in a client component. It's also not a rename-the-name job: Tailwind v4 emits animation: var(--animate-spin) with the keyframe name buried in a custom property, so a correct pass has to rewrite --animate-* definitions and any author-defined variable too — worth doing properly in its own change rather than half-done in this one. Widgets stay as they are until then.


Generated by Claude Code

A view rebuilt while you are looking at it now fades in over the previous
build (200ms, 4px blur) instead of cutting. The outgoing build stays mounted
underneath for the length of the animation, so the incoming one dissolves
over the view rather than over an empty panel — no blank frame. Only that
swap animates: first open and every tab switch stay instant.

Also raise the active view's <style> to the end of <head>. Applet CSS is
scoped per container, but global at-rule names (@Keyframes, @Property) are
not (server/bundler/applet-css.ts), so with several views mounted at once the
last definition of a shared name wins for all of them. Making that the view
on screen keeps the collision invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaXfVTPUsTUpLkFhjFNU3y
@molefrog
molefrog merged commit cfe1bc7 into main Aug 6, 2026
1 check passed
@molefrog
molefrog deleted the claude/view-switching-animation-tom5e3 branch August 6, 2026 09:52
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