This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 7ec3dda8d feat(web): sync document title and roll back partial 
sessions (#6135)
7ec3dda8d is described below

commit 7ec3dda8d20fc303d133c7f3ffd80bce9efa4a07
Author: messere <[email protected]>
AuthorDate: Mon Oct 12 15:33:36 2026 +0800

    feat(web): sync document title and roll back partial sessions (#6135)
    
    Two changes from the same author to shared web-layer plumbing — the page 
header now owns the
    browser-tab title, and the auth session no longer persists halfway — folded 
into a single
    commit.
    
    - #6135 `web/src/components/PageHeader.tsx:30-47` is a pure arrow component 
and nothing under
      `web/` ever writes `document.title`, so all 19 call sites leave the tab 
on the static
      `RocketMQ Studio` from `web/index.html:7` no matter which page is open; 
turns it into a function
      component whose effect sets `document.title` to `${documentTitle ?? 
title} | RocketMQ Studio` on
      mount and restores the value captured before mount on unmount, and adds 
an optional
      `documentTitle?: string | false` prop where `false` leaves the title 
alone — `title` is already
      typed `string` (`:24`) so the interpolation cannot produce `[object 
Object]`, the
      `[documentTitle, title]` deps make the tab follow a language switch since 
callers pass an
      already-translated `title`, and the save/restore order is safe across 
route changes because
      React runs the destroy phase before the create phase in the same commit; 
the JSX, heading level
      and font sizes are unchanged.
    - #6138 `web/src/stores/authStorage.ts:47-55` discards the results of its 
three writes, and
      `writeLocalStorage` / `removeLocalStorage` swallow any failure into a 
boolean
      (`web/src/utils/browserStorage.ts:26-33` and `:35-42`), so a quota error 
on the second write
      leaves a half-persisted session that `readAuthSession` (`:38-45`) reads 
back as a user with
      `userId` and `admin` null — `web/src/layouts/MainLayout.tsx:79` then 
hides the admin entries
      with no explanation, and this runs on every auth confirmation, not only 
at login
      (`web/src/App.tsx:64`, `:97`); collects the three booleans and calls 
`clearAuthSession()` when
      any of them is false, so the stored session is all-or-none, covering the
      `userId == null` branch that goes through `removeLocalStorage` as well, 
and leaving the
      in-memory state alone (`web/src/stores/authStore.ts:35-38` still sets it 
after the persist).
    
    Four new cases (3 for #6135, 1 for #6138), all mutation-sensitive: deleting 
the title effect
    fails `expect(document.title).toBe('Topics | RocketMQ Studio')`, deleting 
the cleanup fails the
    restore-after-unmount assertion, dropping the `documentTitle === false` 
short-circuit fails the
    opt-out case, and removing the rollback leaves the successfully written 
user and admin keys in
    storage after the injected `QuotaExceededError`, failing both `toBeNull()` 
assertions. Neither
    test leaks into its neighbours — `PageHeader.test.tsx` is that component's 
first test file and
    resets `document.title` in an `afterEach`, and the auth case spies on 
`Storage.prototype.setItem`,
    delegates every other write to the original implementation and restores in 
a `finally`, leaving
    the file's five existing cases untouched.
    
    Folded: #6138 was closed as folded into this commit (same author).
---
 web/src/components/PageHeader.tsx                | 57 ++++++++++++++++--------
 web/src/components/__tests__/PageHeader.test.tsx | 52 +++++++++++++++++++++
 web/src/stores/authStorage.test.ts               | 28 +++++++++++-
 web/src/stores/authStorage.ts                    | 16 ++++---
 4 files changed, 128 insertions(+), 25 deletions(-)

diff --git a/web/src/components/PageHeader.tsx 
b/web/src/components/PageHeader.tsx
index c9b0253f0..14157b1fc 100644
--- a/web/src/components/PageHeader.tsx
+++ b/web/src/components/PageHeader.tsx
@@ -16,7 +16,7 @@
  */
 
 import { Flex, Typography } from 'antd';
-import type { ReactNode } from 'react';
+import { useEffect, type ReactNode } from 'react';
 
 const { Title, Text } = Typography;
 
@@ -25,25 +25,46 @@ interface PageHeaderProps {
   subtitle?: ReactNode;
   extra?: ReactNode;
   headingLevel?: 1 | 2 | 3 | 4 | 5;
+  /** Override the browser-tab title, or set to false to leave it unchanged. */
+  documentTitle?: string | false;
 }
 
-const PageHeader = ({ title, subtitle, extra, headingLevel = 1 }: 
PageHeaderProps) => (
-  <Flex justify="space-between" align="center" gap={16} style={{ marginBottom: 
24 }}>
-    <Flex vertical gap={4} style={{ minWidth: 0 }}>
-      <Title
-        level={headingLevel}
-        style={{ margin: 0, fontSize: 20, fontWeight: 600, lineHeight: 1.3 }}
-      >
-        {title}
-      </Title>
-      {subtitle && (
-        <Text type="secondary" style={{ fontSize: 14, lineHeight: 1.5 }}>
-          {subtitle}
-        </Text>
-      )}
+const PageHeader = ({
+  title,
+  subtitle,
+  extra,
+  headingLevel = 1,
+  documentTitle,
+}: PageHeaderProps) => {
+  useEffect(() => {
+    if (documentTitle === false) return;
+
+    const previousTitle = document.title;
+    document.title = `${documentTitle ?? title} | RocketMQ Studio`;
+
+    return () => {
+      document.title = previousTitle;
+    };
+  }, [documentTitle, title]);
+
+  return (
+    <Flex justify="space-between" align="center" gap={16} style={{ 
marginBottom: 24 }}>
+      <Flex vertical gap={4} style={{ minWidth: 0 }}>
+        <Title
+          level={headingLevel}
+          style={{ margin: 0, fontSize: 20, fontWeight: 600, lineHeight: 1.3 }}
+        >
+          {title}
+        </Title>
+        {subtitle && (
+          <Text type="secondary" style={{ fontSize: 14, lineHeight: 1.5 }}>
+            {subtitle}
+          </Text>
+        )}
+      </Flex>
+      {extra && <Flex gap={8}>{extra}</Flex>}
     </Flex>
-    {extra && <Flex gap={8}>{extra}</Flex>}
-  </Flex>
-);
+  );
+};
 
 export default PageHeader;
diff --git a/web/src/components/__tests__/PageHeader.test.tsx 
b/web/src/components/__tests__/PageHeader.test.tsx
new file mode 100644
index 000000000..873c20128
--- /dev/null
+++ b/web/src/components/__tests__/PageHeader.test.tsx
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import PageHeader from '../PageHeader';
+
+describe('PageHeader document title', () => {
+  const initialTitle = 'RocketMQ Studio';
+
+  afterEach(() => {
+    document.title = initialTitle;
+  });
+
+  it('synchronizes the browser tab and restores the previous title', () => {
+    document.title = initialTitle;
+    const view = render(<PageHeader title="Topics" />);
+
+    expect(screen.getByRole('heading', { name: 'Topics' 
})).toBeInTheDocument();
+    expect(document.title).toBe('Topics | RocketMQ Studio');
+
+    view.unmount();
+    expect(document.title).toBe(initialTitle);
+  });
+
+  it('supports a title override', () => {
+    render(<PageHeader title="主题" documentTitle="Topics" />);
+
+    expect(document.title).toBe('Topics | RocketMQ Studio');
+  });
+
+  it('can leave the existing document title unchanged', () => {
+    document.title = 'Managed by parent';
+    render(<PageHeader title="Topics" documentTitle={false} />);
+
+    expect(document.title).toBe('Managed by parent');
+  });
+});
diff --git a/web/src/stores/authStorage.test.ts 
b/web/src/stores/authStorage.test.ts
index 465d059b2..abdca89fe 100644
--- a/web/src/stores/authStorage.test.ts
+++ b/web/src/stores/authStorage.test.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
 import {
   clearAuthSession,
   persistAuthSession,
@@ -38,6 +38,32 @@ describe('auth session storage', () => {
     expect(localStorage.getItem('token')).toBeNull();
   });
 
+  it('rolls back every session key when a later storage write fails', () => {
+    const originalSetItem = Storage.prototype.setItem;
+    let writeCount = 0;
+    const setItem = vi.spyOn(Storage.prototype, 
'setItem').mockImplementation(function (
+      this: Storage,
+      key: string,
+      value: string,
+    ) {
+      writeCount += 1;
+      if (writeCount === 2) {
+        throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
+      }
+      originalSetItem.call(this, key, value);
+    });
+
+    try {
+      persistAuthSession('studio-admin', 7, true);
+    } finally {
+      setItem.mockRestore();
+    }
+
+    expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull();
+    expect(localStorage.getItem(USER_ID_STORAGE_KEY)).toBeNull();
+    expect(localStorage.getItem(USER_ADMIN_STORAGE_KEY)).toBeNull();
+  });
+
   it('restores display identity independently from the HttpOnly session 
cookie', () => {
     localStorage.setItem(USER_STORAGE_KEY, 'studio-admin');
     localStorage.setItem(USER_ID_STORAGE_KEY, '7');
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
index 8f63329ad..d060dcd77 100644
--- a/web/src/stores/authStorage.ts
+++ b/web/src/stores/authStorage.ts
@@ -45,13 +45,17 @@ export function readAuthSession(): AuthSession {
 }
 
 export function persistAuthSession(user: string, userId: number | null, admin: 
boolean): void {
-  writeLocalStorage(USER_STORAGE_KEY, user);
-  if (userId != null) {
-    writeLocalStorage(USER_ID_STORAGE_KEY, String(userId));
-  } else {
-    removeLocalStorage(USER_ID_STORAGE_KEY);
+  const userWritten = writeLocalStorage(USER_STORAGE_KEY, user);
+  const userIdWritten =
+    userId != null
+      ? writeLocalStorage(USER_ID_STORAGE_KEY, String(userId))
+      : removeLocalStorage(USER_ID_STORAGE_KEY);
+  const adminWritten = writeLocalStorage(USER_ADMIN_STORAGE_KEY, 
String(admin));
+  if (!userWritten || !userIdWritten || !adminWritten) {
+    // Leave no partial session behind: a reload must not restore a display 
identity with keys
+    // missing. The in-memory store keeps working while browser storage is 
unavailable.
+    clearAuthSession();
   }
-  writeLocalStorage(USER_ADMIN_STORAGE_KEY, String(admin));
 }
 
 export function clearAuthSession(): void {

Reply via email to