Repository: penumbra-zone/web
Pull request #2080: minifront: retry status stream
Author: login: turbocrime
Created: 2025-02-25T01:28:23Z | Merged: 2025-03-04T01:56:13Z

## Description of Changes

Retry the status stream in minifront, when it fails due to https://github.com/penumbra-zone/web/pull/2074

zquery doesn't really have a stream-retry this feature, so state management in the slice and in the zquery stream callbacks will collect the stream status for interested components, and schedule re-fetch attempts.

## Checklist Before Requesting Review

- [x] I have ensured that any relevant minifront changes do not cause the existing extension to break.



Diff:
diff --git a/.changeset/tall-crabs-speak.md b/.changeset/tall-crabs-speak.md
new file mode 100644
index 0000000000..f4ba89d3b4
--- /dev/null
+++ b/.changeset/tall-crabs-speak.md
@@ -0,0 +1,5 @@
+---
+'minifront': minor
+---
+
+re-request status stream when failed
diff --git a/apps/minifront/src/components/header/header.tsx b/apps/minifront/src/components/header/header.tsx
index 6a392d904d..5985b04d2f 100644
--- a/apps/minifront/src/components/header/header.tsx
+++ b/apps/minifront/src/components/header/header.tsx
@@ -1,16 +1,24 @@
 import { CondensedBlockSyncStatus } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
 import { IncompatibleBrowserBanner } from '@penumbra-zone/ui-deprecated/components/ui/incompatible-browser-banner';
 import { TestnetBanner } from '@penumbra-zone/ui-deprecated/components/ui/testnet-banner';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
 import { Link } from 'react-router-dom';
 import { getChainId } from '../../fetchers/chain-id';
-import { useStatus } from '../../state/status';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
 import { PagePath } from '../metadata/paths';
 import { MenuBar } from './menu/menu';
+import { useStore } from '../../state';
 
 export const Header = () => {
-  const { data, error } = useStatus();
   const [chainId, setChainId] = useState<string | undefined>();
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
 
   useEffect(() => {
     void getChainId().then(id => setChainId(id));
@@ -21,9 +29,9 @@ export const Header = () => {
       <IncompatibleBrowserBanner />
       <TestnetBanner chainId={chainId} />
       <CondensedBlockSyncStatus
-        fullSyncHeight={data?.fullSyncHeight}
-        latestKnownBlockHeight={data?.latestKnownBlockHeight}
-        error={error}
+        fullSyncHeight={syncData.fullSyncHeight}
+        latestKnownBlockHeight={syncData.latestKnownBlockHeight}
+        error={streamError}
       />
       <div className='flex w-full flex-col items-center justify-between px-6 md:h-[82px] md:flex-row md:gap-12 md:px-12'>
         <HeaderLogo />
diff --git a/apps/minifront/src/components/syncing-dialog/block-progress.tsx b/apps/minifront/src/components/syncing-dialog/block-progress.tsx
new file mode 100644
index 0000000000..bdf72885b0
--- /dev/null
+++ b/apps/minifront/src/components/syncing-dialog/block-progress.tsx
@@ -0,0 +1,21 @@
+import { Text } from '@penumbra-zone/ui-deprecated/Text';
+
+export const BlockProgress = ({
+  fullSyncHeight,
+  latestKnownBlockHeight,
+}: {
+  fullSyncHeight: bigint;
+  latestKnownBlockHeight: bigint;
+}) => {
+  const percentSynced = `${(
+    latestKnownBlockHeight && Number(fullSyncHeight) / Number(latestKnownBlockHeight)
+  ).toLocaleString(undefined, { style: 'percent' })} Synced`;
+
+  const blockSyncedOfLatest = `Block ${fullSyncHeight.toLocaleString()} of ${latestKnownBlockHeight.toLocaleString()}`;
+
+  return (
+    <Text technical as='div'>
+      {`${percentSynced} - ${blockSyncedOfLatest}`}
+    </Text>
+  );
+};
diff --git a/apps/minifront/src/components/syncing-dialog/index.tsx b/apps/minifront/src/components/syncing-dialog/index.tsx
index 7bb4e55d8f..0dbdb3dcaa 100644
--- a/apps/minifront/src/components/syncing-dialog/index.tsx
+++ b/apps/minifront/src/components/syncing-dialog/index.tsx
@@ -1,66 +1,112 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
+import { Code, ConnectError } from '@connectrpc/connect';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
+
+  const [didClose, setDidClose] = useState(false);
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
+
+  const isUnavailable = useMemo(
+    () => streamError instanceof ConnectError && streamError.code === Code.Unavailable,
+    [streamError],
+  );
 
-  const [isOpen, setIsOpen] = useState(false);
+  const dialogErrorMessage = useMemo(() => {
+    if (streamError instanceof ConnectError) {
+      return streamError.rawMessage;
+    } else if (streamError instanceof Error) {
+      return streamError.message;
+    } else if (streamError != null) {
+      return String(streamError);
+    } else {
+      return null;
+    }
+  }, [streamError]);
 
-  useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
+  /** @todo do we need to show all of these distinct states? */
+  const dialogSyncingText = useMemo(() => {
+    if (isUnavailable) {
+      return 'Connection unavailable.';
+    } else if (streamError != null) {
+      return 'Retrying...';
+    } else if (!initialStatus.data) {
+      return 'Querying local block height...';
+    } else if (!status.data) {
+      return 'Fetching remote block height...';
+    } else if (!isSynced) {
+      return 'Decrypting block stream...';
     } else {
-      setIsOpen(false);
+      return 'If you can read this, something is broken.';
     }
-  }, [status?.isCatchingUp]);
+  }, [isUnavailable, streamError, initialStatus.data, status.data, isSynced]);
+
+  const dialogInstructionsText = useMemo(
+    () =>
+      isUnavailable
+        ? 'Please reload the page.'
+        : 'You can click away, but your data may not be current.',
+    [isUnavailable],
+  );
 
   return (
-    <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
-      <Dialog.Content title='Your client is syncing...' zIndex={9999}>
-        <SyncAnimation />
+    <Dialog isOpen={(!isSynced || !!streamError) && !didClose} onClose={() => setDidClose(true)}>
+      <Dialog.Content
+        title={streamError != null ? 'Syncing interrupted' : 'Syncing...'}
+        zIndex={9999}
+      >
+        <div className='text-center'>
+          {streamError != null && globalThis.__DEV__ && (
+            <Text technical color={theme => theme.caution.main} as='div'>
+              Error: {dialogErrorMessage}
+            </Text>
+          )}
+        </div>
+
+        <SyncAnimation pause={!!streamError} />
 
         <div className='text-center'>
           <Text body as='p'>
-            Decrypting blocks to update your local state
+            {dialogSyncingText}
           </Text>
           <Text small as='p'>
-            You can click away, but your data <i>may</i> not be current
+            {dialogInstructionsText}
           </Text>
-          <div className='mt-6'>
-            {!!status?.isCatchingUp && (
-              <Text technical>
-                {!!status.percentSynced && `${status.percentSynced} Synced – `} Block{' '}
-                {status.fullSyncHeight.toString()}{' '}
-                {!!status.latestKnownBlockHeight &&
-                  `of ${status.latestKnownBlockHeight.toString()}`}
-              </Text>
-            )}
-          </div>
-          {!!status?.isCatchingUp && status.latestKnownBlockHeight ? (
-            <RemainingTime
-              fullSyncHeight={status.fullSyncHeight}
-              latestKnownBlockHeight={status.latestKnownBlockHeight}
-            />
-          ) : null}
+          {!!(syncData.fullSyncHeight && syncData.latestKnownBlockHeight) && (
+            <div className='mt-6'>
+              <BlockProgress
+                fullSyncHeight={syncData.fullSyncHeight}
+                latestKnownBlockHeight={syncData.latestKnownBlockHeight}
+              />
+              <RemainingTime
+                fullSyncHeight={syncData.fullSyncHeight}
+                latestKnownBlockHeight={syncData.latestKnownBlockHeight}
+              />
+            </div>
+          )}
         </div>
       </Dialog.Content>
     </Dialog>
   );
 };
-
-const RemainingTime = ({
-  fullSyncHeight,
-  latestKnownBlockHeight,
-}: {
-  fullSyncHeight: bigint;
-  latestKnownBlockHeight: bigint;
-}) => {
-  const { formattedTimeRemaining } = useSyncProgress(fullSyncHeight, latestKnownBlockHeight);
-  return <Text technical>(Estimated time remaining: {formattedTimeRemaining})</Text>;
-};
diff --git a/apps/minifront/src/components/syncing-dialog/remaining-time.tsx b/apps/minifront/src/components/syncing-dialog/remaining-time.tsx
new file mode 100644
index 0000000000..a8b497eeb0
--- /dev/null
+++ b/apps/minifront/src/components/syncing-dialog/remaining-time.tsx
@@ -0,0 +1,17 @@
+import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { Text } from '@penumbra-zone/ui-deprecated/Text';
+
+export const RemainingTime = ({
+  fullSyncHeight,
+  latestKnownBlockHeight,
+}: {
+  fullSyncHeight: bigint;
+  latestKnownBlockHeight: bigint;
+}) => {
+  const { formattedTimeRemaining } = useSyncProgress(fullSyncHeight, latestKnownBlockHeight);
+  return (
+    <Text technical as='div'>
+      {`(Estimated time remaining: ${formattedTimeRemaining})`}
+    </Text>
+  );
+};
diff --git a/apps/minifront/src/components/syncing-dialog/sync-animation.tsx b/apps/minifront/src/components/syncing-dialog/sync-animation.tsx
index c62bca1114..8dc8dbcf16 100644
--- a/apps/minifront/src/components/syncing-dialog/sync-animation.tsx
+++ b/apps/minifront/src/components/syncing-dialog/sync-animation.tsx
@@ -1,27 +1,18 @@
+import { cn } from '@penumbra-zone/ui-deprecated/lib/utils';
 import illustration from './illustration.svg?url';
 
-const FakeButtons = () => (
-  <div className='ml-6 mt-6 flex gap-1'>
-    <div className='size-4 rounded-full bg-light-grey opacity-20' />
-    <div className='size-4 rounded-full bg-light-grey opacity-20' />
-    <div className='size-4 rounded-full bg-light-grey opacity-20' />
-  </div>
-);
-
 /**
  * @todo Use Penumbra UI values for rounding, etc. once its Tailwind config is
  * ready.
  */
-export const SyncAnimation = () => (
+export const SyncAnimation = ({ pause }: { pause: boolean }) => (
   <div className='flex flex-col gap-8 rounded-sm bg-black'>
-    <FakeButtons />
-
     <div className='relative h-0 overflow-hidden pb-[33%]'>
       <div className='absolute left-1/2 top-0 w-full -translate-x-1/2 overflow-hidden'>
         <img
           src={illustration}
           alt='A loading illustration'
-          className='w-full animate-spin [animation-duration:_5s]'
+          className={cn('w-full', '[animation-duration:5s]', !pause && 'animate-spin')}
         />
       </div>
     </div>
diff --git a/apps/minifront/src/components/v2/header/status-popover.tsx b/apps/minifront/src/components/v2/header/status-popover.tsx
index 2a628ec0d7..cd48f47359 100644
--- a/apps/minifront/src/components/v2/header/status-popover.tsx
+++ b/apps/minifront/src/components/v2/header/status-popover.tsx
@@ -4,43 +4,46 @@ import { Button } from '@penumbra-zone/ui-deprecated/Button';
 import { Density } from '@penumbra-zone/ui-deprecated/Density';
 import { Pill } from '@penumbra-zone/ui-deprecated/Pill';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { statusSelector, useStatus } from '../../../state/status.ts';
+import {
+  statusStreamStateSelector,
+  syncPercentSelector,
+  useStatus,
+} from '../../../state/status.ts';
 import { useMemo } from 'react';
+import { useStore } from '../../../state';
 
 export const StatusPopover = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const sync = useStatus({ select: syncPercentSelector });
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   // a ReactNode displaying the sync status in form of a pill
   const pill = useMemo(() => {
-    // isCatchingUp is undefined when the status is not yet loaded
-    if (status?.isCatchingUp === undefined) {
+    if (sync?.percentSyncedNumber === undefined) {
       return null;
     }
 
-    if (status.error) {
+    if (streamError) {
       return <Pill context='technical-destructive'>Block Sync Error</Pill>;
     }
 
-    if (status.percentSyncedNumber === 1) {
+    if (sync.percentSyncedNumber === 1) {
       return <Pill context='technical-success'>Blocks Synced</Pill>;
     }
 
     return <Pill context='technical-caution'>Block Syncing</Pill>;
-  }, [status]);
+  }, [sync, streamError]);
 
   const popoverContext = useMemo<PopoverContext>(() => {
-    if (status?.isCatchingUp === undefined) {
+    if (sync?.percentSyncedNumber === undefined) {
       return 'default';
-    } else if (status.error) {
+    } else if (streamError) {
       return 'error';
-    } else if (status.percentSyncedNumber === 1) {
+    } else if (sync.percentSyncedNumber === 1) {
       return 'success';
     } else {
       return 'caution';
     }
-  }, [status]);
+  }, [sync, streamError]);
 
   return (
     <Popover>
@@ -49,21 +52,21 @@ export const StatusPopover = () => {
           Status
         </Button>
       </Popover.Trigger>
-      {status?.isCatchingUp !== undefined && (
+      {sync?.percentSyncedNumber !== undefined && (
         <Popover.Content context={popoverContext} align='end' side='bottom'>
           <Density compact>
             <div className='flex flex-col gap-4'>
               <div className='flex flex-col gap-2'>
                 <Text technical>Status</Text>
                 {pill}
-                {!!status.error && String(status.error)}
+                {!!streamError && String(streamError)}
               </div>
               <div className='flex flex-col gap-2'>
                 <Text technical>Block Height</Text>
                 <Pill context='technical-default'>
-                  {status.latestKnownBlockHeight !== status.fullSyncHeight
-                    ? `${status.fullSyncHeight} of ${status.latestKnownBlockHeight}`
-                    : `${status.latestKnownBlockHeight}`}
+                  {sync.data?.latestKnownBlockHeight !== sync.data?.fullSyncHeight
+                    ? `${sync.data?.fullSyncHeight} of ${sync.data?.latestKnownBlockHeight}`
+                    : `${sync.data?.latestKnownBlockHeight}`}
                 </Pill>
               </div>
             </div>
diff --git a/apps/minifront/src/components/v2/header/sync-bar.tsx b/apps/minifront/src/components/v2/header/sync-bar.tsx
index 5415a0d4d2..223fef666a 100644
--- a/apps/minifront/src/components/v2/header/sync-bar.tsx
+++ b/apps/minifront/src/components/v2/header/sync-bar.tsx
@@ -1,21 +1,21 @@
-import { statusSelector, useStatus } from '../../../state/status.ts';
+import {
+  statusStreamStateSelector,
+  syncPercentSelector,
+  useStatus,
+} from '../../../state/status.ts';
 import { Progress } from '@penumbra-zone/ui-deprecated/Progress';
+import { useStore } from '../../../state';
 
 export const SyncBar = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const sync = useStatus({ select: syncPercentSelector });
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   return (
     <div className='fixed left-0 top-0 h-1 w-full'>
-      {status?.isCatchingUp === undefined ? (
-        <Progress value={0} loading error={Boolean(status?.error)} />
+      {sync?.percentSyncedNumber !== undefined ? (
+        <Progress value={sync.percentSyncedNumber} />
       ) : (
-        <Progress
-          value={status.percentSyncedNumber}
-          loading={status.isUpdating}
-          error={Boolean(status.error)}
-        />
+        <Progress value={0} loading={sync?.loading} error={Boolean(streamError)} />
       )}
     </div>
   );
diff --git a/apps/minifront/src/fetchers/status.ts b/apps/minifront/src/fetchers/status.ts
index e3b0687298..e54709ad25 100644
--- a/apps/minifront/src/fetchers/status.ts
+++ b/apps/minifront/src/fetchers/status.ts
@@ -1,28 +1,26 @@
 import { ViewService } from '@penumbra-zone/protobuf';
 import { penumbra } from '../penumbra';
+import { CallOptions } from '@connectrpc/connect';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
 
-const getInitialStatus = () =>
-  penumbra
-    .service(ViewService)
-    .status({})
-    .then(status => ({
-      fullSyncHeight: status.fullSyncHeight,
-      latestKnownBlockHeight: status.catchingUp ? undefined : status.fullSyncHeight,
-    }));
-
-export async function* getStatusStream(): AsyncGenerator<{
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
-}> {
-  // `statusStream` sends new data to stream only when a new block is detected.
-  // This can take up to 5 seconds (time of new block generated).
-  // Therefore, we need to do a unary request to start us off.
-  yield await getInitialStatus();
+export const getInitialStatus = async (opt?: CallOptions): Promise<PlainMessage<StatusResponse>> =>
+  toPlainMessage(await penumbra.service(ViewService).status({}, opt));
 
-  for await (const result of penumbra.service(ViewService).statusStream({})) {
-    yield {
-      fullSyncHeight: result.fullSyncHeight,
-      latestKnownBlockHeight: result.latestKnownBlockHeight,
-    };
+/**
+ * Stream status updates. Default timeout of 30 seconds unless specified.
+ *
+ * @param opt connectrpc call options
+ */
+export async function* getStatusStream(
+  opt?: CallOptions,
+): AsyncGenerator<PlainMessage<StatusStreamResponse>> {
+  for await (const item of penumbra
+    .service(ViewService)
+    .statusStream({}, { timeoutMs: 30_000, ...opt })) {
+    yield toPlainMessage(item);
   }
 }
diff --git a/apps/minifront/src/state/status.test.ts b/apps/minifront/src/state/status.test.ts
new file mode 100644
index 0000000000..d92278bafe
--- /dev/null
+++ b/apps/minifront/src/state/status.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, test, vi } from 'vitest';
+import { useStore } from '.';
+import { syncPercentSelector } from './status';
+import { PlainMessage } from '@bufbuild/protobuf';
+import { StatusStreamResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+
+describe('Status Slice', () => {
+  describe('stream state', () => {
+    test('setStreamError updates error state', () => {
+      const { streamState } = useStore.getState().status;
+      const testError = new Error('Test error');
+
+      streamState.setStreamError(testError);
+
+      expect(useStore.getState().status.streamState.error).toBe(testError);
+    });
+
+    test('setStreamRunning updates running state', () => {
+      const { streamState } = useStore.getState().status;
+
+      // Set a timer to ensure it gets cleared
+      const mockTimer = setTimeout(() => {}, 1000);
+      useStore.setState(state => {
+        state.status.streamState.timer = mockTimer;
+      });
+
+      streamState.setStreamRunning();
+
+      const newStreamState = useStore.getState().status.streamState;
+      expect(newStreamState.running).toBe(true);
+      expect(newStreamState.error).toBeUndefined();
+      expect(newStreamState.timer).toBeUndefined();
+    });
+
+    test('scheduleRefetch sets timer and updates running state', () => {
+      vi.useFakeTimers();
+
+      const revalidateMock = vi.fn<[], void>();
+
+      useStore.setState(state => {
+        state.status.status.revalidate = revalidateMock;
+      });
+
+      const { streamState } = useStore.getState().status;
+
+      streamState.scheduleRefetch();
+
+      // Should have set timer and running state to false
+      expect(useStore.getState().status.streamState.timer).toBeDefined();
+      expect(useStore.getState().status.streamState.running).toBe(false);
+
+      // Advance timers to trigger the reconnect
+      vi.advanceTimersByTime(5000);
+
+      // Should have called revalidate
+      expect(revalidateMock).toHaveBeenCalled();
+
+      vi.useRealTimers();
+    });
+
+    test('scheduleRefetch does not call revalidate if stream is running', () => {
+      vi.useFakeTimers();
+
+      const revalidateMock = vi.fn<[], void>();
+
+      useStore.setState(state => {
+        state.status.status.revalidate = revalidateMock;
+      });
+
+      const { streamState } = useStore.getState().status;
+
+      // Set the stream as running
+      useStore.setState(state => {
+        state.status.streamState.running = true;
+      });
+
+      streamState.scheduleRefetch();
+
+      vi.advanceTimersByTime(1000);
+
+      // oops it's running again
+      useStore.setState(state => {
+        state.status.streamState.running = true;
+      });
+
+      // Advance timers
+      vi.advanceTimersByTime(4000);
+
+      // Should not have called revalidate
+      expect(revalidateMock).not.toHaveBeenCalled();
+
+      vi.useRealTimers();
+    });
+  });
+
+  describe('sync percent', () => {
+    test('syncPercentSelector calculates percentage correctly', () => {
+      const mockState: PlainMessage<StatusStreamResponse> = {
+        fullSyncHeight: 75n,
+        partialSyncHeight: 75n,
+        latestKnownBlockHeight: 100n,
+      };
+
+      const result = syncPercentSelector({
+        data: mockState,
+        loading: false,
+      });
+
+      expect(result.percentSyncedNumber).toBe(0.75);
+      expect(result.percentSynced).toBe('75%');
+    });
+
+    test('syncPercentSelector handles undefined data', () => {
+      const result = syncPercentSelector({
+        data: undefined,
+        loading: false,
+      });
+
+      expect(result.percentSyncedNumber).toBe(0);
+      expect(result.percentSynced).toBe('0%');
+    });
+
+    test('syncPercentSelector caps percentage at 100%', () => {
+      const mockState: PlainMessage<StatusStreamResponse> = {
+        fullSyncHeight: 120n, // Higher than latest block
+        partialSyncHeight: 120n,
+        latestKnownBlockHeight: 100n,
+      };
+
+      const result = syncPercentSelector({
+        data: mockState,
+        loading: false,
+      });
+
+      expect(result.percentSyncedNumber).toBe(1);
+      expect(result.percentSynced).toBe('100%');
+    });
+
+    test('syncPercentSelector rounds down to whole numbers', () => {
+      const mockState: PlainMessage<StatusStreamResponse> = {
+        fullSyncHeight: 761n,
+        partialSyncHeight: 761n,
+        latestKnownBlockHeight: 1000n,
+      };
+
+      const result = syncPercentSelector({
+        data: mockState,
+        loading: false,
+      });
+
+      // Would be 76.1% but rounds down to 76%
+      expect(result.percentSyncedNumber).toBe(0.76);
+      expect(result.percentSynced).toBe('76%');
+    });
+  });
+});
diff --git a/apps/minifront/src/state/status.ts b/apps/minifront/src/state/status.ts
index 447ffbae73..7f1adad918 100644
--- a/apps/minifront/src/state/status.ts
+++ b/apps/minifront/src/state/status.ts
@@ -1,21 +1,67 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
+  name: 'initialStatus',
+  fetch: async () => toPlainMessage(await getInitialStatus()),
+  getUseStore: () => useStore,
+  get: state => state.status.initialStatus,
+  set: setter => {
+    const newState = setter(useStore.getState().status.initialStatus);
+    useStore.setState(state => {
+      state.status.initialStatus = newState;
+    });
+  },
+});
+
+export const { status, useStatus, useRevalidateStatus } = createZQuery({
   name: 'status',
   fetch: getStatusStream,
   stream: () => ({
-    onValue: (_prevState: Status | undefined, item: Status): Status => ({
-      fullSyncHeight: item.fullSyncHeight,
-      latestKnownBlockHeight: item.latestKnownBlockHeight,
-    }),
+    onValue: (
+      prevData: PlainMessage<StatusStreamResponse> | undefined,
+      item: PlainMessage<StatusStreamResponse>,
+    ): PlainMessage<StatusStreamResponse> => {
+      useStore.getState().status.streamState.setStreamRunning();
+      return {
+        ...prevData,
+        ...item,
+      };
+    },
+    onError: (prevData, error) => {
+      console.warn('Status stream error', error);
+      useStore.getState().status.streamState.setStreamError(error);
+      return prevData;
+    },
+    onEnd: prevData => {
+      useStore.getState().status.streamState.scheduleRefetch();
+      return prevData;
+    },
   }),
   getUseStore: () => useStore,
   get: state => state.status.status,
@@ -27,69 +73,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        const timer = setTimeout(() => {
+          const { streamState, status } = get().status;
+          if (!streamState.running) {
+            status.revalidate();
+          }
+        }, RECONNECT_TIMEOUT);
 
-export const createStatusSlice = (): SliceCreator<StatusSlice> => () => ({
-  status,
+        set(state => {
+          state.status.streamState.timer = timer;
+          state.status.streamState.running = false;
+        });
+      },
+    },
+  };
+};
+
+export const statusStreamStateSelector = ({ status }: AllSlices) => ({
+  error: status.streamState.error,
+  running: status.streamState.running,
 });
 
-// Copies the logic from the view service's `status` method.
-export const statusSelector = (
-  zQueryState: AbridgedZQueryState<Status>,
-):
-  | {
-      /**
-       * - `undefined` when not loaded
-       * `true` if the sync is behind 10 blocks the current state of blockchain, or if not synced at all
-       * `false` otherwise
-       */
-      isCatchingUp: undefined;
-      error: unknown;
-    }
-  | {
-      isCatchingUp: boolean;
-      isUpdating: boolean;
-      fullSyncHeight: bigint;
-      latestKnownBlockHeight?: bigint;
-      percentSynced?: string;
-      percentSyncedNumber: number;
-      error: unknown;
-    } => {
-  if (!zQueryState.data?.fullSyncHeight) {
-    return { isCatchingUp: undefined, error: zQueryState.error };
-  } else {
-    const { fullSyncHeight, latestKnownBlockHeight } = zQueryState.data;
-    const isCatchingUp = !latestKnownBlockHeight || latestKnownBlockHeight - fullSyncHeight > 10;
-    const isUpdating = Boolean(
-      latestKnownBlockHeight &&
-        latestKnownBlockHeight !== fullSyncHeight &&
-        latestKnownBlockHeight - fullSyncHeight <= 10,
-    );
-
-    let percentSyncedNumber = 0;
-    if (latestKnownBlockHeight) {
-      percentSyncedNumber = Number(fullSyncHeight) / Number(latestKnownBlockHeight);
-      if (percentSyncedNumber > 1) {
-        percentSyncedNumber = 1;
-      }
-    }
-
-    // Round down to ensure whole numbers
-    const roundedPercentSyncedNumber = Math.floor(percentSyncedNumber * 100);
-
-    return {
-      error: zQueryState.error,
-      isCatchingUp,
-      isUpdating,
-      fullSyncHeight,
-      latestKnownBlockHeight,
-      percentSyncedNumber: roundedPercentSyncedNumber / 100,
-      percentSynced: `${roundedPercentSyncedNumber}%`,
-    };
-  }
+export const syncPercentSelector = (
+  zQueryState: AbridgedZQueryState<PlainMessage<StatusStreamResponse>>,
+) => {
+  const { fullSyncHeight, latestKnownBlockHeight } = { ...zQueryState.data };
+
+  const synced =
+    Number(latestKnownBlockHeight ?? 0) &&
+    Math.min(1, Number(fullSyncHeight) / Number(latestKnownBlockHeight));
+
+  const roundedPercentSyncedNumber = Math.floor(synced * 100);
+
+  return {
+    ...zQueryState,
+    percentSyncedNumber: roundedPercentSyncedNumber / 100,
+    percentSynced: `${roundedPercentSyncedNumber}%`,
+  };
 };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f1356a6920..ef6c2304df 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2078,6 +2078,7 @@ packages:
 
   '@confio/ics23@0.6.8':
     resolution: {integrity: sha512-wB6uo+3A50m0sW/EWcU64xpV/8wShZ6bMTa7pF8eYsTrSkQA7oLUIJcs/wb8g4y2Oyq701BaGiO6n/ak5WXO1w==}
+    deprecated: Unmaintained. The codebase for this package was moved to https://github.com/cosmos/ics23 but then the JS implementation was removed in https://github.com/cosmos/ics23/pull/353. Please consult the maintainers of https://github.com/cosmos for further assistance.
 
   '@connectrpc/connect-web@1.4.0':
     resolution: {integrity: sha512-13aO4psFbbm7rdOFGV0De2Za64DY/acMspgloDlcOKzLPPs0yZkhp1OOzAQeiAIr7BM/VOHIA3p8mF0inxCYTA==}
@@ -8462,8 +8463,8 @@ packages:
     resolution: {integrity: sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==}
     engines: {node: ^18.19.0 || >=20.5.0}
 
-  exponential-backoff@3.1.1:
-    resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==}
+  exponential-backoff@3.1.2:
+    resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==}
 
   extendable-error@0.1.7:
     resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
@@ -9689,9 +9690,11 @@ packages:
 
   lodash.get@4.4.2:
     resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
+    deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
 
   lodash.isequal@4.5.0:
     resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+    deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
 
   lodash.merge@4.6.2:
     resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -24383,7 +24386,7 @@ snapshots:
   dot-case@3.0.4:
     dependencies:
       no-case: 3.0.4
-      tslib: 2.6.3
+      tslib: 2.8.1
 
   dotenv@16.0.3: {}
 
@@ -25104,7 +25107,7 @@ snapshots:
       strip-final-newline: 4.0.0
       yoctocolors: 2.1.1
 
-  exponential-backoff@3.1.1: {}
+  exponential-backoff@3.1.2: {}
 
   extendable-error@0.1.7: {}
 
@@ -26659,7 +26662,7 @@ snapshots:
 
   metro-cache@0.81.0:
     dependencies:
-      exponential-backoff: 3.1.1
+      exponential-backoff: 3.1.2
       flow-enums-runtime: 0.0.6
       metro-core: 0.81.0
 


Reviews:
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T04:02:21Z
user: login: TalDerei
body: noticeable zquery improvements! I’ve left some initial observations
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:53:49Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T19:36:45Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T19:42:43Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:03:14Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:05:10Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:10:24Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:12:04Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:12:58Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:19:16Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:23:39Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:24:55Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:26:51Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:27:31Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:28:22Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T21:34:43Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T22:18:44Z
user: login: smmhrdmn
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-03T17:01:12Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T19:11:39Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T19:24:12Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T19:39:55Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T19:41:27Z
user: login: TalDerei
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T19:57:07Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T22:58:40Z
user: login: TalDerei
body: fell into an infinite loop of "querying local block height" after locking the wallet mid-sync, then reloading the page following the "connection closed" error. The connection then times out, and reloading the page doesn’t resolve the issue, leaving syncing stuck in a state where it can't self-correct or make progress. Using current prax main and minifront on localhost. 

----------------

<img width="1840" alt="Screenshot 2025-03-03 at 4 11 24 PM" src="https://github.com/user-attachments/assets/d318a8b2-ec54-406b-a56d-5c5285e8f388" />

<img width="1840" alt="Screenshot 2025-03-03 at 4 14 51 PM" src="https://github.com/user-attachments/assets/de2e82b6-4a82-45b4-9336-4f80802ad227" />

https://github.com/user-attachments/assets/606eaaaf-00d6-4864-a976-de91f8630cfa

-----------------

clearing the cache also doesn't resolve it (which is expected since this is frontend related).

------------

<img width="1840" alt="Screenshot 2025-03-03 at 4 17 32 PM" src="https://github.com/user-attachments/assets/281eab35-0541-464c-b33b-de0575ae64b9" />

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T23:15:50Z
user: login: TalDerei
body: don't think this can land until the blocking error I ran into is resolved
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T23:19:14Z
user: login: TalDerei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T23:22:40Z
user: login: turbocrime
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T01:32:54Z
user: login: TalDerei
body: looks good to me!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T01:49:51Z

Review comments:
path: apps/minifront/src/fetchers/status.ts
user: login: turbocrime
body: initial status is now called independently of the status stream, to disambiguate initial (local) status returns and remote status returns.
created_at: 2025-02-25T19:30:09Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,21 @@
 import { ViewService } from '@penumbra-zone/protobuf';
 import { penumbra } from '../penumbra';
+import { CallOptions } from '@connectrpc/connect';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
 
-const getInitialStatus = () =>
-  penumbra
-    .service(ViewService)
-    .status({})
-    .then(status => ({
-      fullSyncHeight: status.fullSyncHeight,
-      latestKnownBlockHeight: status.catchingUp ? undefined : status.fullSyncHeight,
-    }));
-
-export async function* getStatusStream(): AsyncGenerator<{
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
-}> {
-  // `statusStream` sends new data to stream only when a new block is detected.
-  // This can take up to 5 seconds (time of new block generated).
-  // Therefore, we need to do a unary request to start us off.
-  yield await getInitialStatus();
+export const getInitialStatus = async (opt?: CallOptions): Promise<PlainMessage<StatusResponse>> =>
+  toPlainMessage(await penumbra.service(ViewService).status({}, opt));
 
-  for await (const result of penumbra.service(ViewService).statusStream({})) {
-    yield {
-      fullSyncHeight: result.fullSyncHeight,
-      latestKnownBlockHeight: result.latestKnownBlockHeight,
-    };
+export async function* getStatusStream(
+  opt?: CallOptions,
+): AsyncGenerator<PlainMessage<StatusStreamResponse>> {
+  for await (const item of penumbra
+    .service(ViewService)
+    .statusStream({}, { timeoutMs: 15_000, ...opt })) {
path: apps/minifront/src/fetchers/status.ts
user: login: turbocrime
body: a timeout of 15 seconds is applied, unless the caller overrides with a different value.
created_at: 2025-02-25T19:30:25Z
author_association: COLLABORATOR
diff_hunk: @@ -1,21 +1,75 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: the slice now tracks state of the status stream and provides state update methods used by the stream event callbacks
created_at: 2025-02-25T19:33:08Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,24 @@
 import { CondensedBlockSyncStatus } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
 import { IncompatibleBrowserBanner } from '@penumbra-zone/ui-deprecated/components/ui/incompatible-browser-banner';
 import { TestnetBanner } from '@penumbra-zone/ui-deprecated/components/ui/testnet-banner';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
 import { Link } from 'react-router-dom';
 import { getChainId } from '../../fetchers/chain-id';
-import { useStatus } from '../../state/status';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
 import { PagePath } from '../metadata/paths';
 import { MenuBar } from './menu/menu';
+import { useStore } from '../../state';
 
 export const Header = () => {
-  const { data, error } = useStatus();
   const [chainId, setChainId] = useState<string | undefined>();
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
path: apps/minifront/src/components/header/header.tsx
user: login: TalDerei
body: suggestion: we should abstract these stream stalled and signal timeout errors from surfacing in the modal.

-------------

https://github.com/user-attachments/assets/bc88108c-6506-4fa3-aff0-2d6855999a9e


created_at: 2025-02-27T01:03:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: comment: It switches quickly between "querying local block height" and  "fetching remote block height", so I think we should synthesize the messaging for these states rather than distinguishing between them. 

----------------------

https://github.com/user-attachments/assets/7c7a39a2-815f-43af-9d0c-d29a931da8d0


created_at: 2025-02-27T01:14:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
     }
-  }, [status?.isCatchingUp]);
+
+    const shouldShow = !isSynced || !!streamError;
+    setIsOpen(shouldShow && !didClose);
+  }, [didClose, initialStatus.data, status.data, isSynced, streamError]);
 
   return (
-    <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
+    <Dialog isOpen={isOpen} onClose={() => setDidClose(true)}>
       <Dialog.Content title='Your client is syncing...' zIndex={9999}>
         <SyncAnimation />
 
         <div className='text-center'>
-          <Text body as='p'>
-            Decrypting blocks to update your local state
+          <Text body>
+            {dialogText}
+            {!!streamError && (
+              <Text technical color={theme => theme.caution.main} as='div'>
+                {streamError instanceof Error ? streamError.message : String(streamError)}
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: comment: clearing the cache surfaces some interesting errors

--------------------

https://github.com/user-attachments/assets/60c96fe9-a9e5-4dbd-814e-da7218e8ffc9




created_at: 2025-02-27T01:19:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: do we wants a fixed connection timeout rather than an exponential backoff? I think the former may be more preferable here as implemented.
created_at: 2025-02-27T01:23:32Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: dialog is significantly modified
created_at: 2025-02-27T04:02:14Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,21 @@
 import { ViewService } from '@penumbra-zone/protobuf';
 import { penumbra } from '../penumbra';
+import { CallOptions } from '@connectrpc/connect';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
 
-const getInitialStatus = () =>
-  penumbra
-    .service(ViewService)
-    .status({})
-    .then(status => ({
-      fullSyncHeight: status.fullSyncHeight,
-      latestKnownBlockHeight: status.catchingUp ? undefined : status.fullSyncHeight,
-    }));
-
-export async function* getStatusStream(): AsyncGenerator<{
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
-}> {
-  // `statusStream` sends new data to stream only when a new block is detected.
-  // This can take up to 5 seconds (time of new block generated).
-  // Therefore, we need to do a unary request to start us off.
-  yield await getInitialStatus();
+export const getInitialStatus = async (opt?: CallOptions): Promise<PlainMessage<StatusResponse>> =>
+  toPlainMessage(await penumbra.service(ViewService).status({}, opt));
 
-  for await (const result of penumbra.service(ViewService).statusStream({})) {
-    yield {
-      fullSyncHeight: result.fullSyncHeight,
-      latestKnownBlockHeight: result.latestKnownBlockHeight,
-    };
+export async function* getStatusStream(
+  opt?: CallOptions,
+): AsyncGenerator<PlainMessage<StatusStreamResponse>> {
+  for await (const item of penumbra
+    .service(ViewService)
+    .statusStream({}, { timeoutMs: 15_000, ...opt })) {
path: apps/minifront/src/fetchers/status.ts
user: login: TalDerei
body: suggestion: can we add a comment here to the effect of "the timeout specifies how long the client will wait for a response before considering the streaming request failed"?
created_at: 2025-02-27T17:03:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
+  name: 'initialStatus',
+  fetch: async () => toPlainMessage(await getInitialStatus()),
+  getUseStore: () => useStore,
+  get: state => state.status.initialStatus,
+  set: setter => {
+    const newState = setter(useStore.getState().status.initialStatus);
+    useStore.setState(state => {
+      state.status.initialStatus = newState;
+    });
+  },
+});
+
+export const { status, useStatus, useRevalidateStatus } = createZQuery({
   name: 'status',
   fetch: getStatusStream,
   stream: () => ({
-    onValue: (_prevState: Status | undefined, item: Status): Status => ({
-      fullSyncHeight: item.fullSyncHeight,
-      latestKnownBlockHeight: item.latestKnownBlockHeight,
-    }),
+    onValue: (
+      prevData: PlainMessage<StatusStreamResponse> | undefined,
+      item: PlainMessage<StatusStreamResponse>,
+    ): PlainMessage<StatusStreamResponse> => {
+      useStore.getState().status.streamState.setStreamRunning();
+      return {
+        ...prevData,
+        ...item,
+      };
+    },
+    onError: (prevData, error) => {
+      useStore.getState().status.streamState.setStreamError(error);
+      return prevData;
+    },
+    onEnd: prevData => {
+      useStore.getState().status.streamState.scheduleRefetch();
+      return prevData;
+    },
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: ACK – reconnection logic has `onEnd` callback which triggers `scheduleRefetch()`

-----------

https://github.com/user-attachments/assets/a10c706e-9807-4dcc-8dd4-c6851daf69d0



created_at: 2025-02-27T18:16:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,69 +72,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        const timer = setTimeout(() => {
+          const { streamState, status } = get().status;
+          if (!streamState.running) {
+            status.revalidate();
+          }
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: ACK – calls `status.revalidate()` to restart the stream
created_at: 2025-02-27T18:19:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,69 +72,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: potential race condition here? `scheduleRefetch` checks if `streamState.running` is false before attempting reconnection, however what if the stream status changes between when the timeout is set and when it executes?
created_at: 2025-02-27T18:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,69 +72,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        const timer = setTimeout(() => {
+          const { streamState, status } = get().status;
+          if (!streamState.running) {
+            status.revalidate();
+          }
+        }, RECONNECT_TIMEOUT);
 
-export const createStatusSlice = (): SliceCreator<StatusSlice> => () => ({
-  status,
+        set(state => {
+          state.status.streamState.timer = timer;
+          state.status.streamState.running = false;
+        });
+      },
+    },
+  };
+};
+
+export const statusStreamStateSelector = ({ status }: AllSlices) => ({
+  error: status.streamState.error,
+  running: status.streamState.running,
 });
 
-// Copies the logic from the view service's `status` method.
-export const statusSelector = (
-  zQueryState: AbridgedZQueryState<Status>,
-):
-  | {
-      /**
-       * - `undefined` when not loaded
-       * `true` if the sync is behind 10 blocks the current state of blockchain, or if not synced at all
-       * `false` otherwise
-       */
-      isCatchingUp: undefined;
-      error: unknown;
-    }
-  | {
-      isCatchingUp: boolean;
-      isUpdating: boolean;
-      fullSyncHeight: bigint;
-      latestKnownBlockHeight?: bigint;
-      percentSynced?: string;
-      percentSyncedNumber: number;
-      error: unknown;
-    } => {
-  if (!zQueryState.data?.fullSyncHeight) {
-    return { isCatchingUp: undefined, error: zQueryState.error };
-  } else {
-    const { fullSyncHeight, latestKnownBlockHeight } = zQueryState.data;
-    const isCatchingUp = !latestKnownBlockHeight || latestKnownBlockHeight - fullSyncHeight > 10;
-    const isUpdating = Boolean(
-      latestKnownBlockHeight &&
-        latestKnownBlockHeight !== fullSyncHeight &&
-        latestKnownBlockHeight - fullSyncHeight <= 10,
-    );
-
-    let percentSyncedNumber = 0;
-    if (latestKnownBlockHeight) {
-      percentSyncedNumber = Number(fullSyncHeight) / Number(latestKnownBlockHeight);
-      if (percentSyncedNumber > 1) {
-        percentSyncedNumber = 1;
-      }
-    }
-
-    // Round down to ensure whole numbers
-    const roundedPercentSyncedNumber = Math.floor(percentSyncedNumber * 100);
-
-    return {
-      error: zQueryState.error,
-      isCatchingUp,
-      isUpdating,
-      fullSyncHeight,
-      latestKnownBlockHeight,
-      percentSyncedNumber: roundedPercentSyncedNumber / 100,
-      percentSynced: `${roundedPercentSyncedNumber}%`,
-    };
-  }
+export const syncPercentSelector = (
+  zQueryState: AbridgedZQueryState<PlainMessage<StatusStreamResponse>>,
+) => {
+  const { fullSyncHeight, latestKnownBlockHeight } = { ...zQueryState.data };
+
+  const synced =
+    Number(latestKnownBlockHeight ?? 0) &&
+    Math.min(1, Number(fullSyncHeight) / Number(latestKnownBlockHeight));
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: sync percentage calculations may run into a division by zero error if `latestKnownBlockHeight` is zero?
created_at: 2025-02-27T18:26:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
+  name: 'initialStatus',
+  fetch: async () => toPlainMessage(await getInitialStatus()),
+  getUseStore: () => useStore,
+  get: state => state.status.initialStatus,
+  set: setter => {
+    const newState = setter(useStore.getState().status.initialStatus);
+    useStore.setState(state => {
+      state.status.initialStatus = newState;
+    });
+  },
+});
+
+export const { status, useStatus, useRevalidateStatus } = createZQuery({
   name: 'status',
   fetch: getStatusStream,
   stream: () => ({
-    onValue: (_prevState: Status | undefined, item: Status): Status => ({
-      fullSyncHeight: item.fullSyncHeight,
-      latestKnownBlockHeight: item.latestKnownBlockHeight,
-    }),
+    onValue: (
+      prevData: PlainMessage<StatusStreamResponse> | undefined,
+      item: PlainMessage<StatusStreamResponse>,
+    ): PlainMessage<StatusStreamResponse> => {
+      useStore.getState().status.streamState.setStreamRunning();
+      return {
+        ...prevData,
+        ...item,
+      };
+    },
+    onError: (prevData, error) => {
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: what's the difference here between `onError` versus `onEnd` in terms of reconnections?
created_at: 2025-02-27T18:28:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
path: apps/minifront/src/state/status.ts
user: login: TalDerei
body: comment: `useRevalidateInitialStatus` not used
created_at: 2025-02-27T18:41:23Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: comment: the dialogue feels too cramped – can we revert to the previous dialogue (with the sync percentage) while keeping the message "Streaming new blocks to update private state"?

-------------

_before_

<img width="453" alt="Screenshot 2025-02-27 at 12 33 09 PM" src="https://github.com/user-attachments/assets/86688686-5ca5-4032-a828-252f6a9d2565" />

-----------------

_after_

<img width="483" alt="Screenshot 2025-02-27 at 12 33 15 PM" src="https://github.com/user-attachments/assets/219b32d3-2b20-438c-82bf-3f03eebcbce7" />

created_at: 2025-02-27T19:36:45Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: thoughts? cc @smmhrdmn
created_at: 2025-02-27T19:42:43Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: sure, no aesthetic preference here, i was primarily seeking to have the displayed status reflect the new state information
created_at: 2025-02-27T21:03:14Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,24 @@
 import { CondensedBlockSyncStatus } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
 import { IncompatibleBrowserBanner } from '@penumbra-zone/ui-deprecated/components/ui/incompatible-browser-banner';
 import { TestnetBanner } from '@penumbra-zone/ui-deprecated/components/ui/testnet-banner';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
 import { Link } from 'react-router-dom';
 import { getChainId } from '../../fetchers/chain-id';
-import { useStatus } from '../../state/status';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
 import { PagePath } from '../metadata/paths';
 import { MenuBar } from './menu/menu';
+import { useStore } from '../../state';
 
 export const Header = () => {
-  const { data, error } = useStatus();
   const [chainId, setChainId] = useState<string | undefined>();
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
path: apps/minifront/src/components/header/header.tsx
user: login: turbocrime
body: it's important to display some kind of failure to the user, when the request has failed.

if you want to conceal the failure message, can you suggest a replacement?

should different failures use different replacements? this quickly gets complex.
created_at: 2025-02-27T21:05:10Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: these are distinct states.

the cases you've tested involve a quick transition, and it is often a quick transition, but the lack of distinction between these states is at the root of the issue in #2081.

specifically, the 'Querying local block height' state is the 'initial status' unary request that will pend and possibly time out if the extension is connected, but taking a long time to respond to RPC queries.

i don't think making these states ambiguous is a good idea. there is no downside to making these states distinct. the upside is that users are more informed of information that is highly useful for issue reports and investigation.
created_at: 2025-02-27T21:10:24Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: i'm disinclined to make this change. please suggest a different dialog message if you think it could be better
created_at: 2025-02-27T21:12:04Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
     }
-  }, [status?.isCatchingUp]);
+
+    const shouldShow = !isSynced || !!streamError;
+    setIsOpen(shouldShow && !didClose);
+  }, [didClose, initialStatus.data, status.data, isSynced, streamError]);
 
   return (
-    <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
+    <Dialog isOpen={isOpen} onClose={() => setDidClose(true)}>
       <Dialog.Content title='Your client is syncing...' zIndex={9999}>
         <SyncAnimation />
 
         <div className='text-center'>
-          <Text body as='p'>
-            Decrypting blocks to update your local state
+          <Text body>
+            {dialogText}
+            {!!streamError && (
+              <Text technical color={theme => theme.caution.main} as='div'>
+                {streamError instanceof Error ? streamError.message : String(streamError)}
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: clearing the cache through this menu will restart the extension service worker. once the extension service worker is restarted, the page content scripts lose context and it's not possible to recover from that without a reload.

ideally this loss of context would be transmitted to the page, but the data clone error you see is the https://github.com/penumbra-zone/web/issues/2070 issue resolved by https://github.com/penumbra-zone/web/pull/2071 so unless your prax build in this review included those changes, that is expected to be present as a data clone error.

edit: also relevant https://github.com/penumbra-zone/web/issues/1885
created_at: 2025-02-27T21:12:58Z
author_association: COLLABORATOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: attempting an exponential backoff implementation here would become significantly more complex, due to the fact that the query is not a single awaited promise over which we have direct control, but a zquery stream.

i agree backoff would be better, but this PR is essentially a spike to land a single case of stream re-request without getting deep into zquery. any more than this becomes significant zquery work.

i would like to consider new zquery features to be out of scope for this PR.

these are low-cost queries, so i don't think it's a big deal.
created_at: 2025-02-27T21:19:16Z
author_association: COLLABORATOR
diff_hunk: @@ -27,69 +72,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: if the stream is running when the timeout executes, a reconnect is not necessary.
created_at: 2025-02-27T21:23:39Z
author_association: COLLABORATOR
diff_hunk: @@ -27,69 +72,69 @@ export const { status, useStatus } = createZQuery({
   },
 });
 
-export interface StatusSlice {
-  status: ZQueryState<{
-    fullSyncHeight?: bigint;
-    latestKnownBlockHeight?: bigint;
-  }>;
-}
+export const createStatusSlice = (): SliceCreator<StatusSlice> => (set, get) => {
+  return {
+    initialStatus, // zquery initialStatus object
+    status, // zquery status object
+    streamState: {
+      error: undefined,
+      running: false,
+      timer: undefined,
+
+      setStreamError: (error: unknown) => {
+        set(state => {
+          state.status.streamState.error = error;
+        });
+      },
+
+      setStreamRunning: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        set(state => {
+          state.status.streamState.error = undefined;
+          state.status.streamState.running = true;
+          state.status.streamState.timer = undefined;
+        });
+      },
+
+      scheduleRefetch: () => {
+        clearTimeout(get().status.streamState.timer);
+
+        const timer = setTimeout(() => {
+          const { streamState, status } = get().status;
+          if (!streamState.running) {
+            status.revalidate();
+          }
+        }, RECONNECT_TIMEOUT);
 
-export const createStatusSlice = (): SliceCreator<StatusSlice> => () => ({
-  status,
+        set(state => {
+          state.status.streamState.timer = timer;
+          state.status.streamState.running = false;
+        });
+      },
+    },
+  };
+};
+
+export const statusStreamStateSelector = ({ status }: AllSlices) => ({
+  error: status.streamState.error,
+  running: status.streamState.running,
 });
 
-// Copies the logic from the view service's `status` method.
-export const statusSelector = (
-  zQueryState: AbridgedZQueryState<Status>,
-):
-  | {
-      /**
-       * - `undefined` when not loaded
-       * `true` if the sync is behind 10 blocks the current state of blockchain, or if not synced at all
-       * `false` otherwise
-       */
-      isCatchingUp: undefined;
-      error: unknown;
-    }
-  | {
-      isCatchingUp: boolean;
-      isUpdating: boolean;
-      fullSyncHeight: bigint;
-      latestKnownBlockHeight?: bigint;
-      percentSynced?: string;
-      percentSyncedNumber: number;
-      error: unknown;
-    } => {
-  if (!zQueryState.data?.fullSyncHeight) {
-    return { isCatchingUp: undefined, error: zQueryState.error };
-  } else {
-    const { fullSyncHeight, latestKnownBlockHeight } = zQueryState.data;
-    const isCatchingUp = !latestKnownBlockHeight || latestKnownBlockHeight - fullSyncHeight > 10;
-    const isUpdating = Boolean(
-      latestKnownBlockHeight &&
-        latestKnownBlockHeight !== fullSyncHeight &&
-        latestKnownBlockHeight - fullSyncHeight <= 10,
-    );
-
-    let percentSyncedNumber = 0;
-    if (latestKnownBlockHeight) {
-      percentSyncedNumber = Number(fullSyncHeight) / Number(latestKnownBlockHeight);
-      if (percentSyncedNumber > 1) {
-        percentSyncedNumber = 1;
-      }
-    }
-
-    // Round down to ensure whole numbers
-    const roundedPercentSyncedNumber = Math.floor(percentSyncedNumber * 100);
-
-    return {
-      error: zQueryState.error,
-      isCatchingUp,
-      isUpdating,
-      fullSyncHeight,
-      latestKnownBlockHeight,
-      percentSyncedNumber: roundedPercentSyncedNumber / 100,
-      percentSynced: `${roundedPercentSyncedNumber}%`,
-    };
-  }
+export const syncPercentSelector = (
+  zQueryState: AbridgedZQueryState<PlainMessage<StatusStreamResponse>>,
+) => {
+  const { fullSyncHeight, latestKnownBlockHeight } = { ...zQueryState.data };
+
+  const synced =
+    Number(latestKnownBlockHeight ?? 0) &&
+    Math.min(1, Number(fullSyncHeight) / Number(latestKnownBlockHeight));
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: no - if `latestKnownBlockHeight` is zero, the conditional is short-circuited and `synced` is simply zero.
created_at: 2025-02-27T21:24:55Z
author_association: COLLABORATOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
+  name: 'initialStatus',
+  fetch: async () => toPlainMessage(await getInitialStatus()),
+  getUseStore: () => useStore,
+  get: state => state.status.initialStatus,
+  set: setter => {
+    const newState = setter(useStore.getState().status.initialStatus);
+    useStore.setState(state => {
+      state.status.initialStatus = newState;
+    });
+  },
+});
+
+export const { status, useStatus, useRevalidateStatus } = createZQuery({
   name: 'status',
   fetch: getStatusStream,
   stream: () => ({
-    onValue: (_prevState: Status | undefined, item: Status): Status => ({
-      fullSyncHeight: item.fullSyncHeight,
-      latestKnownBlockHeight: item.latestKnownBlockHeight,
-    }),
+    onValue: (
+      prevData: PlainMessage<StatusStreamResponse> | undefined,
+      item: PlainMessage<StatusStreamResponse>,
+    ): PlainMessage<StatusStreamResponse> => {
+      useStore.getState().status.streamState.setStreamRunning();
+      return {
+        ...prevData,
+        ...item,
+      };
+    },
+    onError: (prevData, error) => {
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: according to zquery documentation, `onEnd` executes on every stream end. `onError` executes before `onEnd`, if there is an error from the fetch.
created_at: 2025-02-27T21:26:51Z
author_association: COLLABORATOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
+  name: 'initialStatus',
+  fetch: async () => toPlainMessage(await getInitialStatus()),
+  getUseStore: () => useStore,
+  get: state => state.status.initialStatus,
+  set: setter => {
+    const newState = setter(useStore.getState().status.initialStatus);
+    useStore.setState(state => {
+      state.status.initialStatus = newState;
+    });
+  },
+});
+
+export const { status, useStatus, useRevalidateStatus } = createZQuery({
   name: 'status',
   fetch: getStatusStream,
   stream: () => ({
-    onValue: (_prevState: Status | undefined, item: Status): Status => ({
-      fullSyncHeight: item.fullSyncHeight,
-      latestKnownBlockHeight: item.latestKnownBlockHeight,
-    }),
+    onValue: (
+      prevData: PlainMessage<StatusStreamResponse> | undefined,
+      item: PlainMessage<StatusStreamResponse>,
+    ): PlainMessage<StatusStreamResponse> => {
+      useStore.getState().status.streamState.setStreamRunning();
+      return {
+        ...prevData,
+        ...item,
+      };
+    },
+    onError: (prevData, error) => {
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: there is another method, `onAbort`, which i investigated but it does not seem to be involved in the cases we care about here.
created_at: 2025-02-27T21:27:31Z
author_association: COLLABORATOR
diff_hunk: @@ -1,21 +1,66 @@
 import { AbridgedZQueryState, ZQueryState } from '@penumbra-zone/zquery/src/types';
-import { SliceCreator, useStore } from '.';
+import { AllSlices, SliceCreator, useStore } from '.';
 import { createZQuery } from '@penumbra-zone/zquery';
-import { getStatusStream } from '../fetchers/status';
+import { getInitialStatus, getStatusStream } from '../fetchers/status';
+import {
+  StatusResponse,
+  StatusStreamResponse,
+} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
+import { PlainMessage, toPlainMessage } from '@bufbuild/protobuf';
 
-export interface Status {
-  fullSyncHeight?: bigint;
-  latestKnownBlockHeight?: bigint;
+// Time in milliseconds to wait before attempting to reconnect the status stream
+const RECONNECT_TIMEOUT = 5_000;
+
+export interface StatusStreamState {
+  error?: unknown;
+  running: boolean;
+  timer?: ReturnType<typeof setTimeout>;
+  scheduleRefetch: () => void;
+  setStreamError: (error: unknown) => void;
+  setStreamRunning: () => void;
+}
+
+export interface StatusSlice {
+  initialStatus: ZQueryState<PlainMessage<StatusResponse>>;
+  status: ZQueryState<PlainMessage<StatusStreamResponse>>;
+  streamState: StatusStreamState;
 }
 
-export const { status, useStatus } = createZQuery({
+export const { initialStatus, useInitialStatus, useRevalidateInitialStatus } = createZQuery({
path: apps/minifront/src/state/status.ts
user: login: turbocrime
body: export of the `useRevalidate` generated method is consistent with other zquery use
created_at: 2025-02-27T21:28:22Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
     }
-  }, [status?.isCatchingUp]);
+
+    const shouldShow = !isSynced || !!streamError;
+    setIsOpen(shouldShow && !didClose);
+  }, [didClose, initialStatus.data, status.data, isSynced, streamError]);
 
   return (
-    <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
+    <Dialog isOpen={isOpen} onClose={() => setDidClose(true)}>
       <Dialog.Content title='Your client is syncing...' zIndex={9999}>
         <SyncAnimation />
 
         <div className='text-center'>
-          <Text body as='p'>
-            Decrypting blocks to update your local state
+          <Text body>
+            {dialogText}
+            {!!streamError && (
+              <Text technical color={theme => theme.caution.main} as='div'>
+                {streamError instanceof Error ? streamError.message : String(streamError)}
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: eventually this should be addressed with a more comprehensive attempt at addressing context loss.

i would like to consider this case out of scope for this PR, because no part of minifront handles this case well, and it's a significant effort to address, with many considerations.
created_at: 2025-02-27T21:34:43Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,24 @@
 import { CondensedBlockSyncStatus } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
 import { IncompatibleBrowserBanner } from '@penumbra-zone/ui-deprecated/components/ui/incompatible-browser-banner';
 import { TestnetBanner } from '@penumbra-zone/ui-deprecated/components/ui/testnet-banner';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
 import { Link } from 'react-router-dom';
 import { getChainId } from '../../fetchers/chain-id';
-import { useStatus } from '../../state/status';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
 import { PagePath } from '../metadata/paths';
 import { MenuBar } from './menu/menu';
+import { useStore } from '../../state';
 
 export const Header = () => {
-  const { data, error } = useStatus();
   const [chainId, setChainId] = useState<string | undefined>();
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
path: apps/minifront/src/components/header/header.tsx
user: login: TalDerei
body: maybe a more general "connection issue detected" message, rather than anything specific to streams?







created_at: 2025-02-27T22:18:44Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: smmhrdmn
body: I would agree with Tal that the top layout feels less cramped, although it still doesn't provide a great solution for displaying status info. Imo this dialog could use a proper redesign at some point. One small addition is we should probably include comma separators in the block numbers to make them more legible – the percentage is also helpful for this 
created_at: 2025-03-03T17:01:12Z
author_association: NONE
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: restored old layout, with a couple changes

<img width="692" alt="Screenshot 2025-03-03 at 11 01 45" src="https://github.com/user-attachments/assets/d868dcd3-f55e-430d-98ee-38f1069bfd35" />

<img width="692" alt="Screenshot 2025-03-03 at 11 09 37" src="https://github.com/user-attachments/assets/a8766c7a-ca43-4cd0-a26e-d4bc22d56ca5" />

created_at: 2025-03-03T19:11:39Z
author_association: COLLABORATOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: and the required reload case,

<img width="692" alt="Screenshot 2025-03-03 at 11 23 53" src="https://github.com/user-attachments/assets/62c3cf52-4578-4405-957c-9d836499c8c6" />

created_at: 2025-03-03T19:24:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: discussed offline, added todo comment
created_at: 2025-03-03T19:39:55Z
author_association: CONTRIBUTOR
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: discussed offline, hiding the error behind dev flag
created_at: 2025-03-03T19:41:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,66 +1,80 @@
 import { Dialog } from '@penumbra-zone/ui-deprecated/Dialog';
-import { statusSelector, useStatus } from '../../state/status';
-import { SyncAnimation } from './sync-animation';
 import { Text } from '@penumbra-zone/ui-deprecated/Text';
-import { useEffect, useState } from 'react';
-import { useSyncProgress } from '@penumbra-zone/ui-deprecated/components/ui/block-sync-status';
+import { useEffect, useMemo, useState } from 'react';
+import { useStore } from '../../state';
+import { statusStreamStateSelector, useInitialStatus, useStatus } from '../../state/status';
+import { BlockProgress } from './block-progress';
+import { RemainingTime } from './remaining-time';
+import { SyncAnimation } from './sync-animation';
 
 export const SyncingDialog = () => {
-  const status = useStatus({
-    select: statusSelector,
-  });
+  const initialStatus = useInitialStatus();
+  const status = useStatus();
+  const { error: streamError } = useStore(statusStreamStateSelector);
 
   const [isOpen, setIsOpen] = useState(false);
+  const [didClose, setDidClose] = useState(false);
+  const [dialogText, setDialogText] = useState<string | undefined>();
+
+  const syncData = useMemo(
+    () => ({ ...initialStatus.data, ...status.data }),
+    [initialStatus.data, status.data],
+  );
+
+  const isSynced = useMemo(
+    () =>
+      syncData.fullSyncHeight &&
+      syncData.latestKnownBlockHeight &&
+      syncData.fullSyncHeight >= syncData.latestKnownBlockHeight,
+    [syncData],
+  );
 
   useEffect(() => {
-    if (status?.isCatchingUp) {
-      setIsOpen(true);
-    } else {
-      setIsOpen(false);
+    if (streamError) {
+      setDialogText('Retrying...');
+    } else if (!initialStatus.data) {
+      setDialogText('Querying local block height...');
+    } else if (!status.data) {
+      setDialogText('Fetching remote block height...');
+    } else if (!isSynced) {
+      setDialogText('Streaming new blocks to update private state...');
     }
-  }, [status?.isCatchingUp]);
+
+    const shouldShow = !isSynced || !!streamError;
+    setIsOpen(shouldShow && !didClose);
+  }, [didClose, initialStatus.data, status.data, isSynced, streamError]);
 
   return (
-    <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
+    <Dialog isOpen={isOpen} onClose={() => setDidClose(true)}>
       <Dialog.Content title='Your client is syncing...' zIndex={9999}>
         <SyncAnimation />
 
         <div className='text-center'>
-          <Text body as='p'>
-            Decrypting blocks to update your local state
+          <Text body>
+            {dialogText}
+            {!!streamError && (
+              <Text technical color={theme => theme.caution.main} as='div'>
+                {streamError instanceof Error ? streamError.message : String(streamError)}
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: discussed offline, we should open a separate issue for this case.
created_at: 2025-03-03T22:58:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,11 +73,11 @@ export const SyncingDialog = () => {
   return (
     <Dialog isOpen={(!isSynced || !!streamError) && !didClose} onClose={() => setDidClose(true)}>
       <Dialog.Content
-        title={dialogErrorMessage ? 'Syncing interrupted' : 'Syncing...'}
+        title={streamError != null ? 'Syncing interrupted' : 'Syncing...'}
         zIndex={9999}
       >
         <div className='text-center'>
-          {!!streamError && (
+          {streamError != null && globalThis.__DEV__ && (
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: TalDerei
body: separately, possible to detect data clone errors by matching the error string and modifying the log message to a more user-friendly error that uniquely identifies this type of issue?

created_at: 2025-03-03T23:22:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,11 +73,11 @@ export const SyncingDialog = () => {
   return (
     <Dialog isOpen={(!isSynced || !!streamError) && !didClose} onClose={() => setDidClose(true)}>
       <Dialog.Content
-        title={dialogErrorMessage ? 'Syncing interrupted' : 'Syncing...'}
+        title={streamError != null ? 'Syncing interrupted' : 'Syncing...'}
         zIndex={9999}
       >
         <div className='text-center'>
-          {!!streamError && (
+          {streamError != null && globalThis.__DEV__ && (
path: apps/minifront/src/components/syncing-dialog/index.tsx
user: login: turbocrime
body: specifically, data clone errors observed in the case of content script context loss are a failure of error reporting prax-side, which [already has a fix landed in transport-dom](https://github.com/penumbra-zone/web/issues/2070), waiting to be consumed.

future data clone errors would be indistinguishable, dapp-side, from these data clone errors.

adding special-case behavior for a data clone error here will be of short-lived utility, as clients update and become incapable of emitting this example. it would also obscure other possible unique root causes of data clone errors, which would be of high importance to discover.

in any case, data clone errors caused by content script context loss are transient, and are replaced within 5 seconds by a 'Connection closed' error, which is already special-cased.
created_at: 2025-03-04T01:32:54Z
author_association: COLLABORATOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-02-25T01:28:28Z
body: ###  🦋  Changeset detected

Latest commit: 1244cf55a0c83dd82f259e82a1e4adca2df72b9e

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name      | Type  |
| --------- | ----- |
| minifront | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/penumbra-zone/web/new/minifront-retry-status-stream?filename=.changeset/olive-rockets-roll.md&value=---%0A%22minifront%22%3A%20patch%0A---%0A%0Aminifront%3A%20retry%20status%20stream%0A)


author_association: NONE
user: login: turbocrime
created_at: 2025-03-04T01:15:15Z
body: > fell into an infinite loop of "querying local block height" after locking the wallet mid-sync, then reloading the page following the "connection closed" error. The connection then times out, and reloading the page doesn’t resolve the issue, leaving syncing stuck in a state where it can't self-correct or make progress. Using current prax main and minifront on localhost.

i'm not able to reproduce this.

locking the wallet will destroy the content-script context, as discussed in other threads.

reloading the page should restore connection. i wasn't able to observe a 'stuck' connection after reload.

how long did the 'stuck' connection persist after locking the extension and reloading?

clearing cache will also destroy content-script context, and genesis blocking may time out local requests or connection attempts.

how long did the 'stuck' connection persist after clearing cache and reloading?

if you can reproduce this, we should pair on investigation.
author_association: COLLABORATOR
user: login: TalDerei
created_at: 2025-03-04T01:49:36Z
body: > if you can reproduce this, we should pair on investigation.

afaict, prax wasn't running locally which caused seems to have caused this. 
author_association: CONTRIBUTOR