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 {