Skip to content

fix(app-shell): on a phone, Back with the nav drawer open closes the drawer and stays on the page - #29323

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-a4er0q
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-a4er0q

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

Requested by Steedos · project thread
Fixes #29131

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:手机宽度下,侧边抽屉打开时按返回键(浏览器返回或 Android 返回手势)只关抽屉、留在原页面。抽屉打开时占一条路由历史记录;用其它方式关掉抽屉(点遮罩、Esc、侧栏按钮)会把这条记录退掉;在抽屉里点入口则替换这条记录,所以从新页面返回会回到打开抽屉前的页面。刷新后抽屉保持关闭,不留抽屉记录。桌面宽度不写任何历史。
  • 为什么改:Console at phone width: Navigation flow audit and fixes at 375 and 390 px #29075 审计实测,CRM 与 showcase 在 375/390 px 下 4/4 次:打开抽屉再按返回,直接离开列表跳到 /home。手机用户(含 WebView 外壳的 Android 返回)习惯用返回键关菜单。
  • 风险与回滚:低到中。只在 @object-ui/app-shell 新增一个 hook,在 ConsoleLayout 挂一行;不改 UnifiedSidebar.tsx 和任何 components/src/ui/** 原件。风险点是历史栈:手机上打开抽屉多一条同 URL 的记录,抽屉内链接点击被改为 replace 导航。回滚即 revert 本 PR。
  • 先看:① ui/packages/app-shell/src/layout/usePhoneNavDrawerHistory.ts 的三个 effect,尤其抽屉内链接的捕获监听与 popstate 关闭;② ConsoleLayout.tsx 的挂载行;③ urlParams.ts 新常量 NAV_DRAWER_OPEN_STATE。
  • 两分钟验证:pnpm dev:crm,浏览器 375 px 打开 Leads 列表,点 ☰ 打开抽屉,按浏览器返回:抽屉关上,地址仍是 Leads 列表;再按一次返回才离开。
  • 需要你做的:无,席位落地。

What changes

At phone width (below 768 px) the Console's sidebar is a drawer over the page. Back with the drawer open used to leave the page; now it closes the drawer and the URL stays the page it was on. Implemented as the seat ruled on the card: one router-owned history entry while the drawer is open, no popstate guard.

  • New hook usePhoneNavDrawerHistory (ui/packages/app-shell/src/layout/), mounted once in ConsoleLayoutInner beside useResponsiveSidebar, inside the sidebar provider. It acts only while useSidebar().isMobile is true:
    • Opening the drawer (trigger, edge swipe, shortcut) pushes one entry through the router with the same pathname, search and hash and NAV_DRAWER_OPEN_STATE: true in its state.
    • Arriving on an entry without the flag while the drawer is open closes the drawer (Back). A popstate listener closes it too: BrowserRouter commits the push and a Back pressed before the push committed as one transition, so the router reports no location change at all in that case (found by the live spec, see Tests).
    • Every other close (outside tap, Escape, the trigger) calls navigate(-1), only when the current entry carries the flag.
    • A link chosen inside the drawer replaces the flagged entry: a capture-phase click listener on the document calls navigate(to, { replace: true }) and prevents the default, so Link skips its own push. A link to the page already shown pops the entry instead. This covers the navigation, favorite and recent rows and the drawer's "All apps" link without editing UnifiedSidebar.tsx or @object-ui/layout.
    • A flagged entry this document did not push (a reload keeps history.state) is replaced by one without the flag, and the drawer stays closed. Forward to an entry this document pushed reopens the drawer.
  • NAV_DRAWER_OPEN_STATE sits in urlParams.ts beside RECORD_RELATED_ENLARGE_PUSHED_STATE. Neither the hook nor the constant is exported from the package entry.
  • Desktop is unchanged: with isMobile false the hook writes no history.
  • No metadata key, no server change, no edit to UnifiedSidebar.tsx, sidebar.tsx or any other components/src/ui/** primitive. The native shell's Android Back goes through the WebView's history and needs no bridge code.

Tests

  • Unit (usePhoneNavDrawerHistory-29131.test.tsx, memory router, real SidebarProvider, real UnifiedSidebar and NavigationRenderer links, the sidebar's own route-change close running beside the hook): Back closes the drawer on the same URL; Escape pops the entry and Back goes to the previous page; a nav entry replaces the drawer entry and Back returns to the page the drawer was opened on; the "All apps" link (which closes the drawer itself) does not pop as well; a reload on a flagged entry leaves the drawer closed with the flag gone; Forward reopens the drawer; CONTROL at 1280 px: toggling the sidebar commits no location.
  • Live e2e (ui/e2e/live/phone-nav-drawer-back-29131.spec.ts, Chromium with touch at 375 and 390 px, real history): Back closes the drawer and stays on the list; an outside tap pops the entry and Back goes to the previous page; a nav entry then Back returns to the list; a reload leaves the drawer closed with no flagged entry and the same history length; CONTROL at 1280 px: no history entry. Runs in test:e2e:live:nightly, not the :ci allowlist (see Acceptance notes).
Reverse checks and ablations

One-shot, nothing left in the tree (each restore checked: blob equals HEAD and git diff HEAD empty).

  • E2E with the hook unmounted (the ConsoleLayout mount line taken from the base commit, Console rebuilt and served by vite preview against a showcase backend): 8 of 8 phone tests red, desktop CONTROL green.
  • E2E with only the popstate close ablated (scripts/ablation-replace.mjs, anchor 1 to 0): exactly the two "Back with the drawer open" tests red (375 and 390), the other 7 green.
  • Unit with the hook body ablated (early return): 6 red, desktop CONTROL green.
  • Unit with the link capture listener ablated: exactly the two link tests red (nav entry, "All apps"), 5 green.

Acceptance notes

Three deviations from the ruling's text, accepted by the seat:

  1. A popstate close. The ruling's location-driven close misses a Back pressed before the push commits, because BrowserRouter then reports no location change. Measured red in the live spec; the ablation above reds exactly that pair.
  2. Forward to a drawer entry this document pushed reopens the drawer. Otherwise reopening after a Forward would push a second flagged entry, and Back would stop on a no-op.
  3. A drawer link to the page already shown pops the drawer entry instead of replacing it, for the same no-op reason.
  • The race the live spec found. The first live run was red on "Back with the drawer open" at both widths while a manual run passed: the spec pressed Back right after the push. BrowserRouter commits locations inside startTransition, so the push and the pop collapsed into one commit with an unchanged location and the drawer stayed open. The popstate close fixes it; the ablation above shows it is the load-bearing part.
  • Mounted in ConsoleLayout.tsx, not UnifiedSidebar.tsx, so this PR does not touch the file Console nav drawer takes 300–580 ms to open on the showcase at CPU 4x (46 entries); profile and bring it under 200 ms #29141 edits.
  • A nav entry of kind action that navigates in code pushes as before (only links are intercepted). Back from that page returns to the drawer entry, and the drawer reopens there (the Forward rule), then the next Back closes it.
  • After a reload with the drawer open the flagged entry is replaced in place, as ruled, so the history keeps two entries with the same URL; one Back after such a reload stays on the page.
  • The live spec runs in test:e2e:live:nightly (claim amendment 6576569654 added that one ui/package.json script line). It stays out of :ci for that job's two-minute budget; promotion is a later call once nightly has a run population for it.
  • NOT MEASURED: Android Back inside the native shell, reason: no native shell in this container. It goes through the same WebView history the browser's Back exercises.

Verification

Step 07 of the card on the real Console: production build served at /_console by objectstack dev --fresh (examples/app-crm on its lead list, examples/app-showcase on its task list), Chromium with isMobile and touch, DPR 2. "Before" is the Console built without the mount line (equivalent to main 07eaec2); "after" is the final hook. Script and every screenshot: project files issue-29131/ (step07.cjs, before/, after/).

Check crm 375 crm 390 showcase 375 showcase 390
07a Back with the drawer open closes it, URL unchanged FAIL → pass FAIL → pass FAIL → pass FAIL → pass
07b outside tap closes it, then Back goes to the previous page pass → pass pass → pass pass → pass pass → pass
07c a nav entry, then Back returns to the list, drawer closed pass → pass pass → pass pass → pass pass → pass
07d reload with the drawer open: closed, no flagged entry, same history length pass → pass pass → pass pass → pass pass → pass
1280 px: toggling the sidebar writes no history pass → pass pass → pass pass → pass pass → pass

Before, 07a: {"urlBefore":".../_console/apps/com.example.crm/crm_lead","urlAfter":".../_console/home","drawerStill":false}, the card's reading, in all four runs. After: urlAfter equals urlBefore, drawerStill false, flag false.

Local gates at head 1789809 (merged main 7788eed)

The 34 families derived for the six source paths ran at 1789809. Head a76bb1a merged ten more main commits (to 504ba51) touching none of those paths. Head 9626c78 adds the nightly script line in ui/package.json; it derives 12 more families, run there: 12 of 12 exit 0. --ran over all 46: 46 derived, 46 run, 0 NOT-MEASURED (a derived zero).

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 34 families for the 6 changed paths. Each ran with its exit code captured before any pipe: 34 of 34 exit 0, check:type-check-debt and pnpm --dir ui exec vitest run scripts/ (169 files, 4900 tests) under os-verify-lock. --ran reconciliation: 34 derived, 34 run, 0 NOT-MEASURED (a derived zero).
  • node scripts/check-sdui-manifest.mjs: exit 0 (and --self-test exit 0).
  • Unit, on the merged head: pnpm --dir ui exec vitest run over the new file plus every ConsoleLayout.* and UnifiedSidebar.* test in app-shell/src/layout/__tests__: 31 files, 152 tests passed.
  • pnpm --filter @object-ui/app-shell run type-check (source and test configs): exit 0. pnpm --dir ui run type-check:e2e: exit 0. ESLint on the changed files: 0 errors (4 warnings, all on existing ConsoleLayout.tsx lines).
  • Live e2e on the final hook (Console vite preview, showcase backend): 9 of 9 passed.
  • Diff: 626 changed lines in 7 files, under 3000. Families CI runs are read from this PR's check runs.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

席位核对 · ACCEPT · 2026-11-02T12:44Z

Head-sha: 9626c788b09d8e1e824eee03d1a41a56da27f7f8

  • Shape: draft → main, Fixes #29131 in the body only. 7 files, +625 / −1 (626 lines ≤ 3000). No governed path: check-governed-merges --pr 29323 hit 0 of 7 paths. Author objectstack-fleet[bot]. Clause-②: no and Metadata-protocol: none are line-initial in the body and the changeset. The hook and the marker constant are not exported from @object-ui/app-shell's entry, so no published surface moves. No contract review is owed. The body was patched by the seat with the dev's v2.
  • File surface: inside the claim as amended (6576569654 adds the one ui/package.json script line). The hook usePhoneNavDrawerHistory sits in app-shell/src/layout/ and is mounted in ConsoleLayoutInner. The NAV_DRAWER_OPEN_STATE marker is in urlParams.ts. The PR also carries the unit file, the live spec and the changeset. UnifiedSidebar.tsx and every components/src/ui/** primitive are untouched.
  • Read in the diff, against the rulings:
    • Opening the phone drawer pushes one router-owned entry: same pathname, search and hash, with the marker in state.
    • Back with the drawer open closes it on the same URL.
    • A tap close calls navigate(-1) only when the entry carries the marker.
    • A drawer nav link is caught in the capture phase and replaces the marker entry. react-router's Link skips its own navigate on a defaultPrevented click, so nothing pops twice beside fix(app-shell): phone drawer closes on navigation; 44 px top-bar targets below md #29168's route close.
    • A reloaded marker entry that this document never pushed is replaced, so the drawer stays closed.
    • Desktop writes no history.
  • Deviations, accepted and named in the body:
    • a popstate close for a Back pressed before the push commits, a real race the live spec found;
    • Forward to a marker entry this document pushed reopens the drawer;
    • a drawer link to the current page pops the entry.
  • Run record (in the body): step 07, CRM and showcase, at 375 and 390 px. Before, 07a failed in all 4 runs. After, 5 of 5 checks pass in all 4.
  • Tests: 31 unit files, 152 tests, and the ablation reds exactly the two popstate cases. The live spec now runs in test:e2e:live:nightly. 46 of 46 derived gate families exited 0.
  • Gates (check-runs on this head): 47 success, 13 skipped, 0 failed or pending, including all 16 Test UI shards. All ten required contexts succeeded, or were skipped by their path filter (Build Core).
  • Landing: ordinary queue landing, pr_ready then automerge_enable. Fixes #29131 closes the card on merge.

Generated by Claude Code

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 41448041289 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test UI (10/16) — 失败步骤: Run the ui tests (shard 10/16)

    × the session names the organization: the request leaves while organization/list is still in flight, once; the page waits for the list 191ms
    FAIL   @object-ui/console  src/__tests__/App.permissionsWave-29114.test.tsx > /auth/me/permissions leaves in the organization/list wave when the session names the active organization (objectstack#2911
      ↳ 失败原因: AssertionError: expected [] to have a length of 1 but got +0
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/__tests__/App.permissionsWave-29114.test.tsx — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 3 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit eef116e Nov 2, 2026
61 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-a4er0q branch November 2, 2026 13:18
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

Landed · 2026-11-02T13:42Z · PR #29323 merged through the merge queue as eef116e4. Record: ACCEPT 6577466611 at 9626c788 (Clause-②: no, no contract review owed).

probe before (eef116e4~1) now (origin/main)
usePhoneNavDrawerHistory(); in ui/packages/app-shell/src/layout/ConsoleLayout.tsx (Back with the drawer open closes it) 0 1
phone-nav-drawer-back-29131.spec.ts in ui/package.json (the live spec runs in test:e2e:live:nightly) 0 1

The card closed through Fixes. pm:dispatched comes off with this note.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console at phone width: Back with the nav drawer open leaves the page (to /home) instead of closing the drawer

1 participant