@@ -23,7 +23,6 @@ import { useLocale } from "@/lib/use-locale";
2323import {
2424 DEFAULT_UI_STATE ,
2525 DEFAULT_WORKSPACE_TABS_STATE ,
26- readScrollPosition ,
2726 readUiState ,
2827 readWorkspaceTabs ,
2928 writeScrollPosition ,
@@ -78,24 +77,38 @@ export default function Page() {
7877function App ( ) {
7978 const { locale, setLocale, t } = useLocale ( ) ;
8079 const { state, connected, error } = useSessionContext ( ) ;
81- // Webui-parity 07 — restore UI state synchronously from localStorage
82- // BEFORE the first paint, so a refresh on /?session=A lands on the
83- // same right-panel / sidebar collapsed choice the user previously
84- // had open rather than flashing the default first.
85- const [ persisted ] = useState < UiState > ( ( ) => readUiState ( ) ) ;
86- // Slice 17 — restore the workspace-tabs payload (open tabs +
87- // per-column active ids + column widths + collapsed flags) the
88- // same way. The first paint already knows whether the preview /
89- // tree columns should be open and which tabs are inside them, so
90- // a refresh on the new shell does not flash the empty launcher
91- // before restoring the saved tabs.
92- const [ workspaceTabs ] = useState < WorkspaceTabsState > ( ( ) => readWorkspaceTabs ( ) ) ;
80+ // Webui-parity 07 — restore UI state from localStorage so a refresh on
81+ // /?session=A lands on the same right-panel / sidebar collapsed choice
82+ // the user previously had open rather than flashing the default first.
83+ //
84+ // webui-parity 106 (smoke-report P7-b): the reads moved OUT of the render
85+ // phase. This page is prerendered by the Next.js static export, so the
86+ // server HTML and the client's first (hydration) render must be identical;
87+ // a storage-read state initializer returns defaults on the server but
88+ // stored values on the client, which is a hydration mismatch waiting for
89+ // the first change to the `!state` skeleton to go off. The
90+ // first frame therefore renders from DEFAULT_UI_STATE — the same constants
91+ // the server used — and the effect below applies the stored values right
92+ // after mount, while the skeleton is still up (the SSE snapshot has not
93+ // arrived either). By the time real content replaces the skeleton, the
94+ // restored layout is already in place, so the pre-106 no-flash restore
95+ // behaviour is preserved.
96+ const [ persisted , setPersisted ] = useState < UiState > ( DEFAULT_UI_STATE ) ;
97+ // Slice 17 — restore the workspace-tabs payload (open tabs + per-column
98+ // active ids + column widths + collapsed flags) the same way: defaults on
99+ // the first frame, storage values applied by the mount effect below, so
100+ // a refresh does not flash the empty launcher before the saved tabs land
101+ // — and does not read storage during hydration either.
102+ const [ workspaceTabs , setWorkspaceTabs ] = useState < WorkspaceTabsState > (
103+ DEFAULT_WORKSPACE_TABS_STATE ,
104+ ) ;
93105 // The legacy `panel` mirror is still kept around so the
94106 // toolbar's existing "active panel" highlight survives the
95107 // refactor without a fresh state mirror — slice 17 keeps the
96108 // toolbar / panel highlight working through the new tab strip
97109 // system (the active tab's kind is the toolbar highlight).
98- const [ panel , setPanel ] = useState < typeof persisted . panel > ( persisted . panel ) ;
110+ // Seeded null (the default) and restored by the mount effect.
111+ const [ panel , setPanel ] = useState < typeof persisted . panel > ( null ) ;
99112 // Settings is a dialog rather than a drawer panel, so it has its own state.
100113 const [ settingsOpen , setSettingsOpen ] = useState ( false ) ;
101114 const [ settingsSection , setSettingsSection ] = useState < "general" | "connection" | "providers" > ( "general" ) ;
@@ -104,23 +117,44 @@ function App() {
104117 const [ sessionHint , setSessionHint ] = useState < { kind : "not-found" ; sessionId : string } | null > ( null ) ;
105118 const alertCount = useAlertCount ( ) ;
106119
107- // Workspace tabs live-state (slice 17). The `useState` initializer
108- // seeds from the persisted payload; subsequent edits mutate via
109- // the pure reducers and the effect below mirrors them back into
110- // `lib/persist.ts` storage.
111- const [ tabState , setTabState ] = useState < TabStripState > ( workspaceTabs . tabStrip ) ;
112- const [ columnState , setColumnState ] = useState < typeof DEFAULT_COLUMN_LAYOUT > ( workspaceTabs . columnLayout ) ;
113- // Slice 17 — WorkspaceColumns self-measures via
114- // ResizeObserver, so the page does not need to feed
115- // containerWidth anymore. viewportWidth is still threaded
116- // through for the future auto-collapse ladder; today it is
117- // accepted but unused inside computeColumnLayout.
118- const viewportWidth = typeof window !== "undefined" ? window . innerWidth : 1280 ;
120+ // Workspace tabs live-state (slice 17). Seeded from the DEFAULT tab strip
121+ // (see the hydration note above); the mount effect below applies the
122+ // persisted payload, and subsequent edits mutate via the pure reducers,
123+ // mirrored back into `lib/persist.ts` storage by the gated effect.
124+ const [ tabState , setTabState ] = useState < TabStripState > (
125+ DEFAULT_WORKSPACE_TABS_STATE . tabStrip ,
126+ ) ;
127+ const [ columnState , setColumnState ] = useState < typeof DEFAULT_COLUMN_LAYOUT > (
128+ DEFAULT_WORKSPACE_TABS_STATE . columnLayout ,
129+ ) ;
130+ // webui-parity 106 — false until the mount effect has applied the stored
131+ // UI state. The three write-back mirrors below are gated on it: without
132+ // the gate, the first (defaults-seeded) render would overwrite the user's
133+ // stored payload with DEFAULT_UI_STATE before the restore ever ran.
134+ const [ uiRestored , setUiRestored ] = useState ( false ) ;
135+
136+ // The one client-side storage read. Runs after mount — never during
137+ // render, never during hydration — and applies everything in one batch,
138+ // so the skeleton frame the user is still looking at is the last frame
139+ // painted from defaults.
140+ useEffect ( ( ) => {
141+ const restoredUi = readUiState ( ) ;
142+ const restoredTabs = readWorkspaceTabs ( ) ;
143+ setPersisted ( restoredUi ) ;
144+ setWorkspaceTabs ( restoredTabs ) ;
145+ setTabState ( restoredTabs . tabStrip ) ;
146+ setColumnState ( restoredTabs . columnLayout ) ;
147+ setPanel ( restoredUi . panel ) ;
148+ setUiRestored ( true ) ;
149+ } , [ ] ) ;
119150
120151 // Mirror panel changes into localStorage. The write helper is
121152 // debounced; mounting/de-mounting the panel quickly during a
122- // refresh never floods storage.
153+ // refresh never floods storage. Gated on `uiRestored` so the
154+ // defaults-seeded first render cannot clobber the stored payload
155+ // (webui-parity 106).
123156 useEffect ( ( ) => {
157+ if ( ! uiRestored ) return ;
124158 writeUiState ( {
125159 ...DEFAULT_UI_STATE ,
126160 ...persisted ,
@@ -129,15 +163,23 @@ function App() {
129163 // intentionally not adding `persisted` to deps — the persist
130164 // module already guards the debounced write.
131165 // eslint-disable-next-line react-hooks/exhaustive-deps
132- } , [ panel ] ) ;
166+ } , [ panel , uiRestored ] ) ;
133167
134168 // Slice 17 — mirror workspace-tabs state into localStorage. The
135169 // debounced writer coalesces open + activate + scroll edits into
136- // one write.
170+ // one write. Gated on `uiRestored` for the same reason as above.
137171 useEffect ( ( ) => {
172+ if ( ! uiRestored ) return ;
138173 writeWorkspaceTabs ( { tabStrip : tabState , columnLayout : columnState } ) ;
139174 // eslint-disable-next-line react-hooks/exhaustive-deps
140- } , [ tabState , columnState ] ) ;
175+ } , [ tabState , columnState , uiRestored ] ) ;
176+
177+ // Slice 17 — WorkspaceColumns self-measures via
178+ // ResizeObserver, so the page does not need to feed
179+ // containerWidth anymore. viewportWidth is still threaded
180+ // through for the future auto-collapse ladder; today it is
181+ // accepted but unused inside computeColumnLayout.
182+ const viewportWidth = typeof window !== "undefined" ? window . innerWidth : 1280 ;
141183
142184 // ============================================================
143185 // Workspace tabs reducers (the page wires every action through
@@ -477,6 +519,12 @@ function App() {
477519
478520 useEffect ( ( ) => {
479521 if ( ! urlRestored ) return ;
522+ // webui-parity 106 — same gate as the other write mirrors: this effect
523+ // can fire before the mount restore has applied the stored payload
524+ // (SSE sometimes names a session before the restore batch lands), and
525+ // writing from the defaults-seeded `persisted` would drop the stored
526+ // appearance / sidebar choice.
527+ if ( ! uiRestored ) return ;
480528 const active = state ?. mcodeSessionId ?? null ;
481529 writeUiState ( {
482530 ...DEFAULT_UI_STATE ,
@@ -485,7 +533,7 @@ function App() {
485533 lastSessionId : active ,
486534 } ) ;
487535 // eslint-disable-next-line react-hooks/exhaustive-deps
488- } , [ state ?. mcodeSessionId , urlRestored ] ) ;
536+ } , [ state ?. mcodeSessionId , urlRestored , uiRestored ] ) ;
489537
490538 useEffect ( ( ) => {
491539 const onPop = ( ) => {
@@ -739,7 +787,16 @@ function App() {
739787}
740788
741789/**
742- * Scroll-restored chat wrapper — unchanged from slice 07.
790+ * Scroll-restored chat wrapper.
791+ *
792+ * webui-parity 106: the page no longer reads the scroll position out of
793+ * localStorage during render (the pre-106 render-phase read was the third
794+ * instance of the hydration bomb). `Chat` already re-reads the SAME
795+ * per-session key (`webui:scroll:v1:<cid>:<sessionId>`) inside its own
796+ * post-mount restore effect whenever `sessionKey` changes, and it falls back
797+ * to that read whenever no explicit scroll target arrives — so passing
798+ * nothing restores the identical number, from an effect that only runs
799+ * client-side. The page keeps only the write half of the contract.
743800 */
744801function ScrollRestoredChat ( {
745802 t,
@@ -752,13 +809,11 @@ function ScrollRestoredChat({
752809 sessionId : string | null ;
753810 onOpenFile ?: ( path : string ) => void ;
754811} ) {
755- const initial = sessionId ? readScrollPosition ( sessionId ) : 0 ;
756812 return (
757813 < Chat
758814 t = { t }
759815 locale = { locale }
760816 sessionKey = { sessionId }
761- initialScrollTop = { initial }
762817 onScrollPersist = { ( top ) => {
763818 if ( ! sessionId ) return ;
764819 writeScrollPosition ( sessionId , top ) ;
@@ -769,10 +824,9 @@ function ScrollRestoredChat({
769824}
770825
771826// keep the unused-export lint happy: slice 17 deliberately does
772- // not pull `panel` / `openPanel` / `openSettings` / `DEFAULT_UI_STATE`
773- // from the legacy path. They stay in scope so a future ticket can
774- // revive them without re-importing the modules.
827+ // not pull `panel` / `openPanel` / `openSettings` from the legacy
828+ // path. They stay in scope so a future ticket can revive them
829+ // without re-importing the modules.
775830void PreviewColumn ;
776- void DEFAULT_WORKSPACE_TABS_STATE ;
777831void isHtmlPath ;
778832void useRef ;
0 commit comments