Skip to content

Commit 2a0b37b

Browse files
fix(console): a missing public /f/:slug form renders the shared localized not-found state (#27782)
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ Fixes #27756 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:访客打开一个不存在、未发布或已停用的公开表单链接 `/f/SLUG` 时,不再显示英文原文 "Failed to load form (404): 服务端原文",改为显示控制台通用的「页面不存在」空状态(按会话语言显示,不带「返回」按钮);其它加载失败显示本地化的「表单加载失败」横幅,不再回显服务端正文。 - 为什么改:非英文会话的访客会看到一句未翻译的英文报错,且内容含服务端消息;内部 `/forms/NAME` 路由已在 #27446 改为本地化空状态,公开路由需一致。 - 风险与回滚:只动 `FormPage.tsx` 的公开表单加载与错误渲染;未新增或修改任何 i18n 键;内部路由行为不变。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/components/FormPage.tsx` 中 `loadPublicForm` 与 `PublicFormUnavailableError`、以及 `if (notFound && !loaded)` 里 `mode !== 'public'` 一处;再看测试 `FormPage.publicNotFound-27756.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.publicNotFound-27756.test.tsx`,预期 3 个用例全绿(zh 下 404 显示「页面不存在」、500 显示「表单加载失败」,页面均无英文)。 - 需要你做的:无,席位落地。 ## What changes - `loadPublicForm` threw `Failed to load form (STATUS): BODY` for every non-OK `GET /forms/:slug`, and the page rendered that string verbatim in its error banner to the anonymous visitor, in every session language, with the server's body in it. - A 404 from the resolver now throws the module-private `FormNotFoundError` that #27446 added for the internal route (its constructor now takes the message, so each loader names its own subject). The resolver answers 404 for a slug that is unknown, unpublished or no longer anonymous; the page draws the console's shared not-found state with the `console.notFound.title` / `console.notFound.description` keys. - **Go back on the public route:** omitted. The visitor is a guest who arrived from a shared link, often in a fresh tab, so `navigate(-1)` has no console page to return to and is either a dead button or leaves the site. The internal route keeps its Go back button unchanged. - Every other non-OK answer throws a new module-private `PublicFormUnavailableError` (status kept for logs). The page keeps it in its own `publicUnavailable` flag and draws the error banner with the existing `form.errorLoading` key ("Error loading form" / zh 「表单加载失败」), translated at render. The response body is no longer read at all. - No `@object-ui/i18n` key is added and no pack value changes (`Clause-②: no`). All ten packs already carry `console.notFound.*` and `form.errorLoading`. ## Tests - New `ui/apps/console/src/components/FormPage.publicNotFound-27756.test.tsx`, at the real `/f/:slug` route with no provider wrapped around it: - zh, 404: the state shows the zh title and description, and the page body holds no Latin word; - en, 404: `Page not found`, no button at all, no `Failed to load form` and no server body text; - CONTROL, zh, 500: the banner shows 「表单加载失败」, no not-found state, and the page body holds no Latin word (so no server body). - Ablation: with `FormPage.tsx` restored to the merge base `6d8372a8`, all 3 cases fail (`Unable to find an element by: [data-testid="form-not-found"]` twice, `[data-testid="form-load-failed"]` once). Restored from `HEAD`, the file's hash matched the HEAD blob (`b27577ca`). <details><summary>Local runs (head 9390a5a)</summary> - `pnpm --dir ui exec vitest run apps/console/src/components/FormPage` (all `FormPage*` suites): Test Files 40 passed (40), Tests 369 passed (369). - `pnpm --filter @object-ui/console run type-check`: exit 0 (the console tsconfig includes `src`, so the new test is compiled). - `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped (171). - `pnpm check:new-line-citations` (ui): 0 new citations. - `node scripts/check-sdui-manifest.mjs` and the rest of the `dispatch-gates --commands` list (33 families): exit 0 each, reconciled with `--ran` (33 run, 0 NOT-MEASURED). - Before pushing, `ui/` was grepped for `Failed to load form`: the only source hit is the message `PublicFormUnavailableError` keeps for logging (plus the new test's own negative assertion). No existing test pinned the old text. </details> ## Acceptance notes - The public route's not-found description is the shared `console.notFound.description` ("The URL you followed does not match any view in this app."), reused as the card asks. It is accurate for a guest, though it speaks of "views"; a guest-specific line would be a new key and is not in this card's scope. ## Verification In a zh-CN session, open `/_console/f/no_such_slug`: the page shows 「页面不存在」 and its description, with no button and no English text. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7dda2c commit 2a0b37b

3 files changed

Lines changed: 173 additions & 18 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a missing or unavailable public `/f/:slug` form tells the guest so in their language, not as a raw English error (#27756)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The public form route threw `Failed to load form (STATUS): BODY` for any failed `GET /forms/:slug`, and the page rendered that English text verbatim to the anonymous visitor, server body included, whatever the session language.
11+
12+
- **A 404 is "no such form".** The resolver answers 404 for a slug that is unknown, unpublished or no longer anonymous. The page now draws the console's shared not-found state (`console.notFound.title`, `console.notFound.description`), as the internal `/forms/NAME` route does. There is no Go back button on this route: the visitor is a guest from a shared link, with no console page to return to.
13+
- **Every other failure keeps a banner, localized.** It reads the existing `form.errorLoading` key ("Error loading form"). The server's body is never read, so it cannot reach the page.
14+
- No i18n key is added or changed.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#27756 — the public `/f/:slug` route printed the
5+
* loader's English throw, `Failed to load form (STATUS): BODY`, to an
6+
* anonymous visitor in every session language, server body included.
7+
*
8+
* A 404 from the public resolver now draws the console's shared not-found
9+
* state (`console.notFound.*`), as the internal route does since #27446, with
10+
* no "Go back": a guest from a shared link has no console page behind it.
11+
* Every other failure keeps a banner, in the session language
12+
* (`form.errorLoading`), and the server body never reaches the page.
13+
*
14+
* No `I18nProvider` is wrapped around the route, for the reason
15+
* `FormPage.chromeI18n-11039.test.tsx` gives: `createI18n` registers the
16+
* process-global instance every unwrapped consumer reads.
17+
*/
18+
19+
import { describe, expect, it, vi, afterEach } from 'vitest';
20+
import { render, screen } from '@testing-library/react';
21+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
22+
import { createI18n } from '@object-ui/i18n';
23+
import { FormPage } from './FormPage';
24+
25+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
26+
27+
/** A server body no visitor may see; the cases assert it never renders. */
28+
const SERVER_DETAIL = 'No public form configured at /forms/no_such_slug';
29+
30+
/** Answers the public resolver with `status`; nothing else is reachable. */
31+
function stubResolver(status: number) {
32+
return vi.fn(async (url: string) => {
33+
if (!String(url).includes('/forms/no_such_slug')) throw new Error(`unstubbed fetch: ${url}`);
34+
const body = { code: status === 404 ? 'FORM_NOT_FOUND' : 'INTERNAL_ERROR', error: SERVER_DETAIL };
35+
return {
36+
ok: status >= 200 && status < 300,
37+
status,
38+
statusText: 'Error',
39+
json: async () => body,
40+
text: async () => JSON.stringify(body),
41+
} as unknown as Response;
42+
});
43+
}
44+
45+
function renderMissingPublicForm() {
46+
return render(
47+
<MemoryRouter initialEntries={['/f/no_such_slug']}>
48+
<Routes>
49+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
50+
</Routes>
51+
</MemoryRouter>,
52+
);
53+
}
54+
55+
function inLanguage(language: string) {
56+
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
57+
}
58+
59+
afterEach(() => {
60+
vi.unstubAllGlobals();
61+
});
62+
63+
describe('FormPage: a missing public form renders the shared localized not-found state (#27756)', () => {
64+
it('zh: the not-found state is the zh pack\'s, and no English reaches the page', async () => {
65+
inLanguage('zh');
66+
vi.stubGlobal('fetch', stubResolver(404));
67+
renderMissingPublicForm();
68+
69+
const state = await screen.findByTestId('form-not-found');
70+
expect(state).toHaveTextContent('页面不存在');
71+
expect(state).toHaveTextContent('你访问的链接与该应用中的任何视图都不匹配。');
72+
expect(document.body.textContent).not.toMatch(/[A-Za-z]{3,}/);
73+
});
74+
75+
it('en: the same state in English, with no "Go back" for the guest', async () => {
76+
inLanguage('en');
77+
vi.stubGlobal('fetch', stubResolver(404));
78+
renderMissingPublicForm();
79+
80+
const state = await screen.findByTestId('form-not-found');
81+
expect(state).toHaveTextContent('Page not found');
82+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
83+
expect(document.body.textContent).not.toContain('Failed to load form');
84+
expect(document.body.textContent).not.toContain(SERVER_DETAIL);
85+
});
86+
87+
it('CONTROL: a failure that is not a 404 keeps a banner, in zh, without the server body', async () => {
88+
inLanguage('zh');
89+
vi.stubGlobal('fetch', stubResolver(500));
90+
renderMissingPublicForm();
91+
92+
const banner = await screen.findByTestId('form-load-failed');
93+
expect(banner).toHaveTextContent('表单加载失败');
94+
expect(screen.queryByTestId('form-not-found')).not.toBeInTheDocument();
95+
expect(document.body.textContent).not.toMatch(/[A-Za-z]{3,}/);
96+
});
97+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,10 +1595,12 @@ interface LoadedForm {
15951595
/** Public mode: hit the anonymous `/forms/:slug` resolver. */
15961596
async function loadPublicForm(slug: string): Promise<LoadedForm> {
15971597
const res = await apiFetch(`/forms/${encodeURIComponent(slug)}`);
1598-
if (!res.ok) {
1599-
const body = await res.text().catch(() => '');
1600-
throw new Error(`Failed to load form (${res.status}): ${body || res.statusText}`);
1601-
}
1598+
// The resolver answers 404 for a slug that is unknown, unpublished or no
1599+
// longer anonymous — to a guest, all three mean "no form here".
1600+
if (res.status === 404) throw new FormNotFoundError(`No public form at /forms/${slug}`);
1601+
// Any other failure is told to the guest in their language; the server's
1602+
// body is never read, so it can never reach the page.
1603+
if (!res.ok) throw new PublicFormUnavailableError(res.status);
16021604
const payload = (await res.json()) as PublicFormPayload;
16031605
return {
16041606
// objectui#8408 — `payload.form?.title` sits AHEAD of the API-name arm.
@@ -1720,23 +1722,39 @@ export function resolveInternalForm(
17201722
}
17211723

17221724
/**
1723-
* The internal route's "no such form" (objectstack-ai/objectstack#27446): the
1724-
* `/meta/view` read answered 404. The metadata route answers 404 both for a
1725-
* view that does not exist and for one the caller may not read, on purpose, so
1726-
* the page says the one thing both mean — nothing here by that name.
1725+
* "No such form" on either route (objectstack-ai/objectstack#27446, #27756):
1726+
* the internal `/meta/view` read or the public `/forms/:slug` resolver
1727+
* answered 404. Both answer 404 for a form that does not exist and for one the
1728+
* caller may not read, on purpose, so the page says the one thing both mean —
1729+
* nothing here by that name.
17271730
*
17281731
* A type of its own, not a message to match, so {@link FormPage} can tell it
17291732
* from every other load failure and draw the console's shared localized
1730-
* not-found state for it instead of this English text. The message is kept
1731-
* for anyone who logs the error; the page never renders it.
1733+
* not-found state for it instead of English text. The message is kept for
1734+
* anyone who logs the error; the page never renders it.
17321735
*/
17331736
class FormNotFoundError extends Error {
1734-
constructor(name: string) {
1735-
super(`Form metadata not found: view/${name}`);
1737+
constructor(message: string) {
1738+
super(message);
17361739
this.name = 'FormNotFoundError';
17371740
}
17381741
}
17391742

1743+
/**
1744+
* The public resolver failed for a reason other than "no such form"
1745+
* (objectstack-ai/objectstack#27756). The visitor is anonymous, so the page
1746+
* shows the localized `form.errorLoading` line and never this message or the
1747+
* server's body; the status is kept for anyone who logs the error.
1748+
*/
1749+
class PublicFormUnavailableError extends Error {
1750+
readonly status: number;
1751+
constructor(status: number) {
1752+
super(`Failed to load form (${status})`);
1753+
this.name = 'PublicFormUnavailableError';
1754+
this.status = status;
1755+
}
1756+
}
1757+
17401758
/**
17411759
* Internal mode: pull the FormView metadata directly + the target object's
17421760
* schema, and — when the URL names a record (objectui#4278) — that record.
@@ -1761,7 +1779,7 @@ async function loadInternalForm(
17611779
channel?: ApiFetch,
17621780
): Promise<LoadedForm> {
17631781
const viewRes = await apiFetch(`/meta/view/${encodeURIComponent(name)}`, undefined, channel);
1764-
if (viewRes.status === 404) throw new FormNotFoundError(name);
1782+
if (viewRes.status === 404) throw new FormNotFoundError(`Form metadata not found: view/${name}`);
17651783
if (!viewRes.ok) {
17661784
throw new Error(`Could not load form metadata (${viewRes.status}): view/${name}`);
17671785
}
@@ -2371,6 +2389,13 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
23712389
* for it and never shows the thrown text (objectstack-ai/objectstack#27446).
23722390
*/
23732391
const [notFound, setNotFound] = useState(false);
2392+
/**
2393+
* The public resolver failed for another reason
2394+
* ({@link PublicFormUnavailableError}). A flag, not a string in `error`, so
2395+
* the banner's copy follows the session language at render and the thrown
2396+
* text never reaches the anonymous visitor (objectstack-ai/objectstack#27756).
2397+
*/
2398+
const [publicUnavailable, setPublicUnavailable] = useState(false);
23742399
/**
23752400
* The rows the last submit refused, by field name (objectstack-ai/objectstack#27417).
23762401
* The `error` banner and the outcome toast still say the whole refusal; this
@@ -2457,6 +2482,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24572482
setLoading(true);
24582483
setError(null);
24592484
setNotFound(false);
2485+
setPublicUnavailable(false);
24602486
setFieldErrors({});
24612487
// A `?recordId=` we cannot make sense of never reaches a loader: it is an
24622488
// edit intent this route cannot honour, and #4278 ruling point 2 says
@@ -2483,6 +2509,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24832509
// editable form on screen behind the error.
24842510
setLoaded(null);
24852511
if (e instanceof FormNotFoundError) setNotFound(true);
2512+
else if (e instanceof PublicFormUnavailableError) setPublicUnavailable(true);
24862513
else setError(e?.message ?? String(e));
24872514
}
24882515
})
@@ -2859,20 +2886,37 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28592886
// The console's shared not-found state, the one its catch-all route
28602887
// (`RouteNotFound` in app-shell's `AppContent`) draws, with the same pack
28612888
// keys: a form is a view, and a missing one is a URL that matches none.
2889+
// The public route draws no "Go back": its visitor is an anonymous guest
2890+
// who arrived from a shared link, with no console page behind it to
2891+
// return to (objectstack-ai/objectstack#27756).
28622892
return (
28632893
<div className="flex min-h-[60vh] w-full items-center justify-center p-8" data-testid="form-not-found">
28642894
<Empty>
28652895
<EmptyTitle>{t('console.notFound.title')}</EmptyTitle>
28662896
<EmptyDescription>{t('console.notFound.description')}</EmptyDescription>
2867-
<div className="mt-4">
2868-
<Button variant="outline" onClick={() => navigate(-1)}>
2869-
{t('console.notFound.back')}
2870-
</Button>
2871-
</div>
2897+
{mode !== 'public' && (
2898+
<div className="mt-4">
2899+
<Button variant="outline" onClick={() => navigate(-1)}>
2900+
{t('console.notFound.back')}
2901+
</Button>
2902+
</div>
2903+
)}
28722904
</Empty>
28732905
</div>
28742906
);
28752907
}
2908+
if (publicUnavailable && !loaded) {
2909+
return (
2910+
<div className="mx-auto max-w-2xl p-6">
2911+
<div
2912+
className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
2913+
data-testid="form-load-failed"
2914+
>
2915+
{t('form.errorLoading')}
2916+
</div>
2917+
</div>
2918+
);
2919+
}
28762920
if (error && !loaded) {
28772921
return (
28782922
<div className="mx-auto max-w-2xl p-6">

0 commit comments

Comments
 (0)