Skip to content

Commit ea4dcf9

Browse files
fix(app-shell): an app whose navigation holds only doc entries opens its first doc (#29769)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbLCajCYUR31RRZKTSbAZhke)_ Fixes #29678 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台打开一个应用的根地址时,若导航里没有对象、页面、仪表盘、报表或组件条目可落地,就打开成员能看到的第一个文档条目(应用内文档门户)。设计器预览里的“首页”提示同步这条规则。 - **为什么改**:导航只有文档条目的应用,打开后主区域一片空白,没有跳转也没有空状态,管理员、读者、外部成员都一样(#29672 QA 实测,本 PR 现场复现)。 - **风险与回滚**:只在原来什么都不落地的情况下生效;第一条是文档、后面有对象的应用照旧落到第一个对象。文档的可读性由服务端把关:成员读不了的文档条目服务端已从导航里去掉,此时仍是“无权限”空状态。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/console/AppContent.tsx` 的 `resolveLandingRoute`(兜底顺序)与 `findFirstRoute`(类型集合参数化);`ui/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx` 的 `findFirstLanding`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/AppContent.docOnlyLanding-29678.test.tsx`,应见 7 个用例全绿:纯文档应用落到 `/apps/APP/docs/BOOK`,各对照组照旧。 - **需要你做的**:无,席位落地。 ## What changes Claim: issuecomment 6593568853 (round 30), amended by 6593605696 (AppPreview parity). An app whose navigation holds only `doc` entries opened `/apps/APP` to an empty `main`: `findFirstRoute` lands on object, page, dashboard, report and component entries only, and `AppRootLanding` rendered `null` when none landed. - **`AppContent.tsx`**: `findFirstRoute` takes the set of entry types it lands on (default: the five it always took). `resolveLandingRoute` is now: the declared landing, else the first working entry, else the first `doc` entry the member is shown (groups walked, `url` passed over). A `doc` entry's route is the one the sidebar follows, `resolveHref`'s in-app docs portal (`docs/BOOK[/DOC]` under the app), which the console host mounts in its `extraRoutes`. - Docs are a last resort, not a tree-order peer: an app whose first entry is its manual, followed by working entries, still opens on its first working entry, as before and as the designer preview's existing pin states. - The doc is gated as a declared `doc` landing already is (#28450): by the server's audience gate on the app read (it drops an unreadable `doc` entry) and on the doc read. The sidebar's own doc-readability answer (`useNavDocTargetCheck`) is NOT available to the landing without a new wait: it is a lazy `doc`/`book` list read that answers `undefined` (pass every entry) until it loads, so on the landing's first render it never has an answer. Measured live below: a doc behind a book the member may not read never reaches the landing, because the server already dropped its entry. - The pending-read hold (objectui#13097) is unchanged: both of its landing reads go through `resolveLandingRoute`, so a pending bound entry ahead of the doc still holds the skeleton. - **`AppPreview.tsx`** (amendment): `findFirstLanding` mirrors the runtime, so the designer preview names the first doc as the landing when nothing else lands. - The `return null` branch now covers only apps whose entries are all `url`/`action` links or live only under `areas`; its comment says so. ## Tests - `ui/packages/app-shell/src/console/__tests__/AppContent.docOnlyLanding-29678.test.tsx` (7, harness as `AppContent.landingWaitsReads-13097.test.tsx`, with the host's docs route stubbed): a doc-only app lands on its first doc; a doc inside a group behind a `url` link; CONTROL first entry a doc then an object lands on the object; CONTROL object app redirects; CONTROL no navigation keeps `AppNoAccessEmptyState`; CONTROL a pending bound entry ahead of the doc holds the skeleton and lands on the bound entry when shown; the same pending entry failing lands on the doc. - `AppPreview.docEntry-11197.test.tsx`: one new case, a `url` + two `doc` entries names the first doc as the landing. The existing "→ Accounts" pin is unchanged and green. <details><summary>Ablations (scripts/ablation-replace.mjs, mutation verified on disk, restored to HEAD with an empty git diff)</summary> - Drop the doc fallback in `resolveLandingRoute`: 3 failed / 4 passed (the two doc-only cases and the pending-then-fail case red; the four controls green). - Drop the doc pass in `AppPreview`: 1 failed / 3 passed (the new preview case red). </details> <details><summary>Sweep</summary> Every ui test file importing `AppContent` or `AppPreview` (59 files, `git grep -l`), two locked batches at 2 workers, on the merged head e5ff194: 31/31 files 202 tests, 28/28 files 333 tests. The same 59 files were green on d8352d2 before the merge of main (30/30 201, 29/29 334). `@object-ui/app-shell` type-check exit 0 on e5ff194. </details> ## Acceptance notes - Live measurement (showcase `--fresh` backend, Vite console on this branch's source, 1440x900): package `com.qa.r29678` with a published doc `r29678_guide`, a doc-only app `r29678_help` (one `doc` entry), a control app `r29678_ctrl` (one `object` entry), and `r29678_locked_app` whose only entry opens a book gated to a permission set nobody holds. Signed in as admin, as `auditor.demo` (reader) and as `phone.demo` (outsider): - before (AppContent.tsx from `origin/main` in the same running console): `r29678_help` stays at `/apps/com.qa.r29678` with an empty `main` for all three; - after: all three land on `/apps/com.qa.r29678/docs/com.qa.r29678/r29678_guide` (the portal's canonical in-book URL) and read the doc; - control `r29678_ctrl`: `/apps/r29678_ctrl/showcase_task` in both legs, for all three; - `r29678_locked_app`: the server drops the unreadable entry, so all three get "Nothing in R29678 Locked is available to you yet" in both legs; nobody is sent to a refused doc page. Screenshots and the JSON readings are in the project files under `issue-29678/shots/`; the seat links them. - Not changed, reported to the seat as a finding: an app whose entries are all `url`/`action` links, or live only under `areas`, still opens to an empty `main` (measured live on this head: `r29678_urlonly`, one `url` entry, empty `main` for admin, reader and outsider). - `ui/packages/app-shell/src/console/appServesNoNavigation.ts`'s docblock still lists `doc` among the entries the landing resolver never lands on. It is outside this card's file surface, so it is left for the seat. ## Verification Local, `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 35 families on this diff: all 35 run, every one exit 0, `--ran` reconciliation `35 run, 0 NOT-MEASURED, 0 UNRUN` (the ui `vitest run scripts/` suite under the verify lock: 170 files / 4977 tests green). Re-run on the merged head e5ff194: all 35 again exit 0, `--ran` `35 run, 0 NOT-MEASURED, 0 UNRUN` (ui `vitest run scripts/` 170 files / 4977 tests). eslint on the four touched ui files: 0 errors. `dispatch-gates --tier`: no path-derived mandate. Changed lines 431 (under 3000). CI check-runs: see this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRL46SawdHef859orD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57a2612 commit ea4dcf9

5 files changed

Lines changed: 405 additions & 31 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
An app whose navigation holds only `doc` entries now opens its first doc in the in-app docs portal, instead of an empty page (#29678).
9+
10+
- The app root still opens the declared landing first, then the first object, page, dashboard, report or component entry the member is shown. Only when none of those lands does it open the first `doc` entry the member is shown, groups included.
11+
- An app whose first entry is a doc, followed by working entries, opens on its first working entry, as before.
12+
- The doc is gated as a declared `doc` landing is: by the server's audience gate on the app and doc reads.
13+
- The app designer's preview names that doc as the app's landing, as the console does.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,27 +1759,25 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
17591759
);
17601760
}
17611761

1762-
function findFirstRoute(items: any[], ctx?: NavTemplateContext): string {
1762+
// objectui#12113 — a `component` entry lands too: its URL is the one the
1763+
// sidebar follows, through `buildItemRoute` → `resolveHref`. Without it the
1764+
// Studio app, whose navigation is all `component` entries, had no landing at
1765+
// all.
1766+
const WORKING_LANDING_TYPES: ReadonlySet<string> = new Set(['object', 'page', 'dashboard', 'report', 'component']);
1767+
// objectstack-ai/objectstack#29678 — the last resort: a `doc` entry opens the
1768+
// in-app docs portal (`/docs/BOOK[/DOC]` under the app, `resolveHref`).
1769+
const DOC_LANDING_TYPES: ReadonlySet<string> = new Set(['doc']);
1770+
1771+
function findFirstRoute(items: any[], ctx?: NavTemplateContext, types: ReadonlySet<string> = WORKING_LANDING_TYPES): string {
17631772
if (!items || items.length === 0) return '';
17641773
for (const item of items) {
1765-
// objectui#12113 — a `component` entry lands too: its URL is the one the
1766-
// sidebar follows, through `buildItemRoute` → `resolveHref`. Without it
1767-
// the Studio app, whose navigation is all `component` entries, had no
1768-
// landing at all.
1769-
if (
1770-
item.type === 'object' ||
1771-
item.type === 'page' ||
1772-
item.type === 'dashboard' ||
1773-
item.type === 'report' ||
1774-
item.type === 'component'
1775-
) {
1774+
if (types.has(item.type)) {
17761775
const route = buildItemRoute(item, ctx);
17771776
if (route) return route;
17781777
continue;
17791778
}
1780-
if (item.type === 'url') continue;
17811779
if (item.type === 'group' && item.children) {
1782-
const childRoute = findFirstRoute(item.children, ctx);
1780+
const childRoute = findFirstRoute(item.children, ctx, types);
17831781
if (childRoute !== '') return childRoute;
17841782
}
17851783
}
@@ -1819,8 +1817,16 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string {
18191817
* shown (`navigation` arrives already gated, objectui#12426) — `findFirstRoute`
18201818
* walks groups and lands on an `object`, `page`, `dashboard`, `report` or
18211819
* `component` entry (objectui#12113 added `component`). It skips the item
1822-
* types that address nothing inside the app (`url`, `separator`, `action`),
1823-
* and a `doc` entry is never that fallback landing (objectui#11197). Which app
1820+
* types that address nothing inside the app (`url`, `separator`, `action`).
1821+
* A `doc` entry is passed over there: a working entry outranks reading
1822+
* material, so an app whose first entry is its manual still opens on its first
1823+
* working entry. Only when no entry lands does the root open the FIRST `doc`
1824+
* entry the member is shown, in the in-app docs portal
1825+
* (objectstack-ai/objectstack#29678): an app whose navigation holds only docs
1826+
* used to render an empty `main`. That doc is gated as a declared `doc`
1827+
* landing is (objectstack-ai/objectstack#28450): by the server's audience gate
1828+
* on the app read and on the doc read, not by the sidebar's doc-readability
1829+
* answer, which is a lazy list read the root would have to wait on. Which app
18241830
* the ROOT lands on is a separate question, decided by `isDefault`.
18251831
*
18261832
* The retired `app.homePageId` (spec 17.0.0, objectstack#4667) is not read: it
@@ -1837,7 +1843,8 @@ function resolveLandingRoute(
18371843
const declared = resolveDeclaredLandingRoute(app?.landing, shownTrees, evaluateVisibility, (item) =>
18381844
buildItemRoute(item, ctx),
18391845
);
1840-
return declared || findFirstRoute(shownTrees[0] ?? [], ctx);
1846+
const tree = shownTrees[0] ?? [];
1847+
return declared || findFirstRoute(tree, ctx) || findFirstRoute(tree, ctx, DOC_LANDING_TYPES);
18411848
}
18421849

18431850
/**
@@ -1967,11 +1974,12 @@ function AppRootLanding({
19671974
if (appServesNoNavigation(shownApp)) {
19681975
return <AppNoAccessEmptyState app={app} setupPath={setupPath} />;
19691976
}
1970-
// A navigation with no landing: every entry the sidebar draws is a `url`,
1971-
// `action` or `doc` link, or the items live only under `areas`, which the
1972-
// resolver does not walk. The root renders no page of its own and the
1973-
// sidebar offers the entries. It used to render the Studio app's overview,
1974-
// `StudioHomePage`, which objectui#12113 retired.
1977+
// A navigation with no landing: every entry the sidebar draws is a `url` or
1978+
// `action` link, or the items live only under `areas`, which the resolver
1979+
// does not walk (a `doc` entry lands, objectstack-ai/objectstack#29678). The
1980+
// root renders no page of its own and the sidebar offers the entries. It used
1981+
// to render the Studio app's overview, `StudioHomePage`, which objectui#12113
1982+
// retired.
19751983
return null;
19761984
}
19771985

0 commit comments

Comments
 (0)