UI 导航事务:页面栈与视图池必须共享提交边界

背包页跳转商店页时,路由已经入栈而商品目录绑定失败,会留下“返回键认为商店存在、画面仍显示背包”的分裂状态。即使栈没有变化,准备过程若已把 40 项商品写入候选页,单纯归还租约仍会污染下一次打开。本文以证据标识 2026-08-01-1930 的最小探针约束提交与清理边界。

// .tmp/daily-labs/2026-08-01-1930/navigation.js
// ViewPool
export class ViewPool {
  #available = new Map();
  #leased = new Set();
  #defaults = new WeakMap();

  add(route, view) {
    if (!this.#defaults.has(view)) this.#defaults.set(view, { ...view });
    const queue = this.#available.get(route) ?? [];
    queue.push(view);
    this.#available.set(route, queue);
  }

  lease(route) {
    const queue = this.#available.get(route) ?? [];
    const view = queue.shift();
    if (!view) throw new Error(`view unavailable: ${route}`);
    this.#leased.add(view.id);
    return view;
  }

  release(route, view) {
    if (!this.#leased.delete(view.id)) throw new Error("view is not leased");
    for (const key of Object.keys(view)) delete view[key];
    Object.assign(view, this.#defaults.get(view), { visible: false });
    this.add(route, view);
  }
}

所有权边界

NavigationCoordinator 唯一拥有栈、活动视图引用和单调版本;ViewPool 拥有可用实例、租约集合与首次入池时的基础字段;页面自身只拥有租期内绑定的显示数据。系统边界内有三个不变量:栈顶路由必须与活动视图对应,未提交的候选视图不得可见,归还后的实例必须恢复基础字段。系统边界外的数据服务可以让准备失败,但不能直接改栈或绕过池的清理入口。

页面栈、候选视图与视图池的主题主视觉

把池直接改成“按路由返回单例”看似简单,却会让失败绑定污染下一次打开,并使两个并发跳转共享可变对象。租约显式记录占用关系,代价是每条失败路径都必须经过同一归还入口。基础字段快照采用浅复制,适用于本探针的标量绑定状态;真实引擎视图若把绑定结果写进嵌套容器,应由该视图提供明确的重置操作,而不能把浅复制解释成任意对象图回滚。

候选提交

跳转分为租用、准备、版本校验、提交四步。准备发生在私有候选上;只有全部完成后,栈副本、活动引用和版本才一起变化。旧页面在新页面提交后才隐藏,因此正常时序不存在空白帧。

// .tmp/daily-labs/2026-08-01-1930/navigation.js
// NavigationCoordinator.push
async push(route, params) {
  const baseVersion = this.#version;
  const candidate = this.pool.lease(route);
  try {
    await this.prepare(candidate, params);
    if (baseVersion !== this.#version) {
      throw new Error("navigation superseded");
    }
    const previous = this.#active;
    candidate.visible = true;
    this.#stack = [
      ...this.#stack,
      { route, params, view: candidate },
    ];
    this.#active = { route, view: candidate };
    this.#version += 1;
    if (previous) previous.view.visible = false;
    return this.snapshot();
  } catch (error) {
    this.pool.release(route, candidate);
    throw error;
  }
}

版本校验不是并发锁。它只证明候选基于当前导航快照准备;若期间另一跳转已经提交,旧候选必须失败并归还。由此,系统允许异步准备并行,却只允许一个结果改变可见导航状态。

失败回滚

固定失败输入先把 boundItems 写成 40,再发现商店目录版本为 6,而页面要求版本 7。异常在任何栈写入前出现,catchshop-1 交给池统一恢复基础字段并归还;背包页的版本、路由与活动引用保持不变。随后同一实例以 12 项重新租用,准备入口观察不到前一租期的 boundItems。这组回归同时证明“归还租约”和“清除候选状态”缺一不可。

// .tmp/daily-labs/2026-08-01-1930/navigation.test.js
// partially prepared view is clean when leased again
test("partially prepared view is clean when leased again", async () => {
  const pool = createPool();
  let retryStartedClean = false;
  const nav = new NavigationCoordinator(pool, async (view, params) => {
    retryStartedClean = view.boundItems === undefined;
    view.boundItems = params.itemCount;
    if (params.catalogVersion !== 7) throw new Error("catalog mismatch");
  });
  await assert.rejects(
    nav.push("shop", { catalogVersion: 6, itemCount: 40 }),
    /catalog mismatch/,
  );
  assert.deepEqual(nav.snapshot(), {
    version: 0,
    routes: [],
    activeView: null,
  });
  assert.deepEqual(pool.state(), { leased: [] });

  await nav.push("shop", { catalogVersion: 7, itemCount: 12 });
  assert.equal(retryStartedClean, true);
});

正常提交与目录版本失败的导航时序

固定行为

测试与行为探针在同一运行时执行。测试统计为通过 4、失败 0、跳过 0,测试框架耗时 32.801 毫秒,进程退出码为 0。行为输入先以 24 个物品提交背包页,再以 40 个物品和错误目录版本部分准备商店页,最后以 12 个物品重新租用同一商店视图。

$ cd "$WORK_DIR/.tmp/daily-labs/2026-08-01-1930"
$ /usr/bin/time -p npm test
✔ push commits route and prepared view together (0.630167ms)
✔ prepare failure preserves stack and returns lease (0.209583ms)
✔ partially prepared view is clean when leased again (0.125083ms)
✔ missing view fails before navigation state changes (0.33875ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
ℹ skipped 0
ℹ duration_ms 32.800583
real 0.13
user 0.10
sys 0.02
exit_code=0
{
  "evidenceId": "2026-08-01-1930",
  "input": {
    "inventoryItems": 24,
    "failedShopItems": 40,
    "retriedShopItems": 12,
    "expectedCatalogVersion": 7
  },
  "committed": {
    "version": 1,
    "routes": ["inventory"],
    "activeView": "inventory-1"
  },
  "failure": "catalog mismatch",
  "afterFailure": {
    "version": 1,
    "routes": ["inventory"],
    "activeView": "inventory-1"
  },
  "retryStartedClean": true,
  "retried": {
    "version": 2,
    "routes": ["inventory", "shop"],
    "activeView": "shop-1"
  },
  "pool": { "leased": ["inventory-1", "shop-1"] }
}

输出证明的是原子性与租约隔离,不是吞吐性能:失败前后版本仍为 1,栈仍只有背包页;重试入口确认候选干净后,版本才增至 2。当前探针没有测量动画帧耗、内存占用或真实引擎实例化成本,因此不能从 0.13 秒进程时间推导运行时性能。

容量边界

当前实现的 push 复制长度为 D 的栈,时间与额外引用空间均为 O(D);池按路由保存实例,容量为各路由预热数之和。对于菜单深度通常受控、同一路由实例少的界面,这个形状换来了清楚的提交点。若栈深度超过 32、页面参数快照显著增大,或同帧持续出现并发跳转,复制与取消日志就成为演进信号。

正常时序:lease -> prepare -> version check -> stack/view commit -> hide previous
失败时序:lease -> partial prepare -> error -> reset + release -> keep stack/view/version

当前约束:D <= 32,候选视图在提交前不可见,归还恢复浅层基础字段
演进触发:高频并发跳转、跨场景页面、可恢复深链接
下一结构:串行意图队列 + 可取消准备 + 持久化路由快照

串行意图队列能进一步消除候选竞争,但会把慢资源准备变成队头阻塞;完全并行则需要版本门和可靠取消。当前选择保留有限并行,把正确性落在版本化提交与租约归还上。跨场景页面出现时,还需把资源句柄并入候选事务,不能仅复制这份内存栈。

架构结论

页面栈不能在视图准备前先行更新,视图池也不能把失败候选留给下一次跳转。可评审的边界是:导航协调器拥有唯一提交权,视图池拥有租约与归还清理,页面准备只修改不可见候选;成功同时发布栈与活动引用,失败恢复候选基础字段后归还。这个合同在当前标量绑定模型下比全局单例或分散的逐字段回滚更容易证明;当视图引入嵌套绑定对象时,演进点是显式重置协议,而不是继续扩大浅复制的职责。