Keep views mounted across tab switches for instant navigation - #80
Conversation
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
There was a problem hiding this comment.
💡 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".
| // 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) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
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:
betabeta → alpha(parked)2,display: none→blockstyle[data-applet-style]tags still mounted, applet colors still computed, chat composer clickableopacity 0 / blur(4px)over the old build (no blank frame), reaches1 / blur ~0at 186ms, old build dropped at 201msWorth a close look:
ViewSlotacquires 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 withdisplay: none: a parked view keeps its React state but loses scroll position, and its effects unmount and re-run on return. Called out indocs/moi-views.mdfor view authors.<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 inscopeAppletCss.Verification:
bun test— 919 pass, 0 fail (15 new inview-residency.test.ts, covering the retention window, the LRU cap, and deleted-view pruning);bun run lint,format:check, andtypecheck:clientclean; the browser matrix above, plus a widget-grid check that the sharedAppletMountpath still mounts and styles widgets.🤖 Generated with Claude Code
https://claude.ai/code/session_01PaXfVTPUsTUpLkFhjFNU3y