Repository navigation
plugin-form: ObjectForm/WizardForm consume submitBehavior.url against the pre-ruling shape — out-of-contract values silently dropped, same-origin absolutes followed, mount-blind #4989
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 17, 2026 认领(objectui 分片 PM 座席,批次 21)。Session
session_01GTRjn8xBqp75dk7kFupVRt,分支claude/issue-4989-plugin-form-redirect-contract,os-dev 子代理实施。PM 裁定卡面第 4 点的机制方向(契约机械 + 实现选型,公开留否决窗):缺陷 2/3/5 按已落地裁定机械修(响亮拒绝 / relative-only / 插值+转义,契约判据用 spec
FormViewSchema同一份解析 —— plugin-form 依赖面可及,⛔ 不 import console 的模块、⛔ 不写第二份规则);缺陷 4 采用可选读 router context:组件在 React Router context 内则经 router navigate(吃 basename),context 缺席(useContext返回 null 合法)则保留今日window.location.assign语义 —— 无 router 的宿主没有 basename,origin 解析恰是正确行为,不新增已发布 prop。若实测发现该中间路线不可行(context 读不到 / basename 拿不到),按 premise 变化停下报告,⛔ 不改走注入回调(那是新发布面,归决策箱)。navigateOnSuccess是另一契约键,⛔ 不并入(卡面已划界)。消费侧先例:PR4992(console FormPage 同裁定实施件)已落 main。文件面:
packages/plugin-form(ObjectForm/WizardForm/successBehavior + 测试),与在飞席位不相交。
Generated by Claude Code
实施报告(os-dev,session
session_01GTRjn8xBqp75dk7kFupVRt,分支claude/issue-4989-plugin-form-redirect-contract)。Draft PR #5032 —— 卡面缺陷 2、3、5 已修,CI 20 个 check 全部 completed、0 failure(2 个 path-filter skipped)。缺陷 4(挂载盲)未修,升级为决策题,理由是实测,不是畏难。
前提复核:卡面五点全部成立
按 origin/main 逐条验过,无一条过期:两个消费点确实是
isSameOriginUrl(behavior.url)+setTimeout(assign);isSameOriginUrl('https://own-host/thanks')实测为真(缺陷 3);guard 答否时确实什么都不做(缺陷 2);token 确实原样带{{}}进 URL(缺陷 5)。唯一需要更正的是卡面末尾关于navigateOnSuccess的一句 —— 它说该键"shares defects 2 and 4"。缺陷 4 成立,缺陷 2 不完全成立:resolveSuccessNavigate答 null 时调用点会落到toast.success(...),所以不是完全静默,而是"只报成功、不报声明的跳转被拒"。已按精确措辞另开 #5034,没有把这个偏差混进本 PR。缺陷 4:PM 裁定的中间路线,React 那一半成立、依赖那一半不成立
按派发要求先实测两点再动手,实测结论分裂:
① React 语义 —— 成立。 provider 外
useInRouterContext()返回false、useContext(UNSAFE_NavigationContext)返回null且不抛(裁定里"useContext 在 provider 外返回 null 合法"完全正确);挂basename="/_console"时 context 在场、basename读得到。② basename 确实被吃到 ——useNavigate()('/thanks')在 basename 路由下落在/thanks路由上;对照组:同一个字符串交window.location.assign,在注入了 mount 的文档里解析成 origin 根/thanks(而 app-relative 解析会得到/_console/thanks)。依赖面 —— 不成立,这是停下的原因。 React context 是模块实例级的对象,要读 React Router 的 context 就必须 import react-router,没有第二条路。而:
- 本包 dependencies / peerDependencies / optionalDependencies 里都没有 react-router;本仓能解析到它纯粹是仓根 devDependency 抬上来的幻影依赖 ——
scripts/check-phantom-dependencies.mjs(Phantom dependency:reactresolves frompackages/corealthough core declares none — a resolution-based layering check returns the wrong answer #4394)正是为这件事存在,它的文件头还记着plugin-detail同类真缺陷。 - 本包
vite.config.tsexternal 掉所有裸 specifier,静态 import 会在发布的dist/index.js里留下裸react-router-dom,每个消费者都必须解析。而确实有没有 react-router 的消费者:apps/site(把本包列为真依赖,app/components/PluginLoader.tsx里 import)与packages/plugin-view,两者任何字段都没有 react-router。 - 退而求其次的写法都自败:放 dependencies/optionalDependencies 会给本包自己一份 react-router 实例,context 对象和宿主的不是同一个,永远答 null;唯一能保单实例的是 peer,而 peer 恰恰就是强制安装。惰性
import()仍要在消费者构建期解析,且用打包器分析不到的 specifier 绕过是 AGENTS.md 准则 Fix documentation deployment for www.objectui.org #6 明令禁止的。
按派发纪律 ⛔ 未改走注入回调/新 prop,也 ⛔ 未擅自换第三种机制(注入的
base href读法是可行的第三条路,已在实测里量到,但它同样是"已发布渲染器如何发现自己的挂载点"这一架构决定,归决策箱)。三个选项的两轴分析在给 PM 的结构化报告里。今天行为保持不变,
submitRedirect.mountBlind.test.tsx把这个开放缺陷的实测钉住(含 app-relative 对照),免得"衡量过、故意留着"退化成口头传说。⚠️ PR 正文按派发要求逐字写了Fixes #4989,但合并即关闭会连带关掉尚未裁定的第 4 点 —— 合并前请按裁定改成Refs,或先把第 4 点拆成后续 issue。顺带发现(均已另开,未混入本 PR)
- plugin-form: the redirect
delayMstimer outlives the component — a submitter who leaves during the delay is yanked back by a full-page navigation #5033 ——delayMs的setTimeout不绑组件生命周期,延迟窗口内离开的提交者会被一个活过页面的定时器拽回去。PR fix(console): FormPage 的 redirect 按 os#7496 裁定当 in-shell 相对路由,越契约目标响亮拒绝 (#4190) #4992 已在 console 侧修过同一个缺陷(移进 effect + cleanup),形状现成。 - plugin-form: narrow
navigateOnSuccessto the ruled relative-only acceptance set and escape the interpolated id (point 3 — points 1 and 2 landed in PR #5558) #5034 ——navigateOnSuccess挂载盲 + 拒绝时只报通用成功 + 该键的 url 契约根本没被裁定(same-origin 而非 relative-only、{id}单花括号方言、插值不转义)。带Blocked-by: #4989,因为它的挂载修法就是第 4 点裁定的机制。 - finding: the ruled
submitBehavior.urltoken substitution + escape now exists twice (console app, plugin-form package) — the RULE is shared, the consumer mechanics are not #5035(finding标签,无pm:queue)—— 裁定键的 token 替换+转义现在存在两份(console app 与本包)。规则本身没有重复(两边都问 spec 的FormViewSchema),重复的是约 20 行消费机制;两处的刻意差异已在 docblock 里逐条写明,便于将来下沉到共享包。
Generated by Claude Code
- 本包 dependencies / peerDependencies / optionalDependencies 里都没有 react-router;本仓能解析到它纯粹是仓根 devDependency 抬上来的幻影依赖 ——
PM 处置(objectui 分片 PM,session
session_01GTRjn8xBqp75dk7kFupVRt):reopen 转needs-user-decision,按 PR #5032 验收评论预告执行。缺陷 2/3/5 已随 PR #5032 落 main(
ae804eca2):契约外可见拒绝、同源绝对拒绝、插值+转义+fail-closed。本卡现在只持有缺陷 4(挂载盲)的机制决策 —— PM 此前裁定的中间路线(可选读 router context)被实施席两点实测拆解:React 层可行、依赖层不可行(读 context 必须 import react-router;本包四个依赖字段均未声明;apps/site与plugin-view两个真实消费者未安装,发布产物裸 external 会打断其构建;dependency/optionalDependencies 因 pnpm 实例隔离拿到自己的 context 实例恒 null;惰性 import 被诫令 #6 禁止且救不了同步渲染读)。待维护者裁定,选项与测量全文在楼上实施席评论 5318710832,概要:
- B(推荐):读注入的
base href作挂载信号 —— 仓内已正典化的信号(withConsoleBase/resolveHomeUrl同源),实测new URL('./thanks', document.baseURI)正确吃挂载;零依赖变化、零新发布面、AI 作者的相对路径处处同义。代价:仍是整页导航(非 SPA 转场);对「router 有 basename 但没发 base href」的宿主是部分修复。 - A:react-router 声明为(可选)peer + 静态 import —— 换来真 SPA 转场,但发布产物裸 external 会打断
apps/site与一切无 router 嵌入方的构建,花掉本包「任意 React 应用可嵌入」的属性;peerDependenciesMeta只静音安装警告拦不住打包期。 - C:定为 by-design(挂载宿主自己包导航) —— 元数据在 dev 可用、embedded 破裂的最坏失败分布,不建议。
- 不可代裁的一点:若「无整页刷新的 SPA 转场」是本面需求,只有 A 能给 —— 那实质是「plugin-form 是否允许要求 react-router」的包受众定位,产品判断。
关联:#5034(navigateOnSuccess 同类,Blocked-by 本卡)、#5033(delayMs 竞态,已入队)、#5035(消费侧力学两份的漂移记录,池)。
Generated by Claude Code
- B(推荐):读注入的
Maintainer ruling recorded (defect 4 — the remaining open half) — maintainer, 2026-08-17, batch acceptance of the PM decision-inbox recommendations, verbatim: 「同意」. Four-dimension analysis delivered in-session; provenance: PM session
session_01NYgmGheCzM6NrHZN436Cxf.Ruled: optional injected navigation.
plugin-formgains an optional navigation seam (context or prop — implementer's call, stated with reasoning) that a hosting shell (console/app-shell) supplies with its router-aware, basename-aware navigate; when absent, currentwindow.location.assignbehavior stands. Router-presence sniffing is rejected (implicit magic); areact-routerpeer requirement is rejected (imposes resolution on router-less consumers). Explicit injection keeps the published package dependency-free and makes mount-awareness visible at the type face.The console/app-shell wiring (providing the callback) is part of this card's scope — the seam without a supplier changes nothing. This ruling is also the mechanism for #5034 (
navigateOnSuccess), which unblocks when this lands.Label
needs-user-decision→pm:queuein the same stroke.
Generated by Claude Code
Claim: PM seat dispatch (post-ruling; defect 4, the reopened remainder)
Session:session_01NYgmGheCzM6NrHZN436Cxf
Branch:claude/issue-4989-injected-navigation
Scope: the injected-navigation ruling recorded above — optional navigation seam on plugin-form (context or prop, implementer's choice stated with reasoning), app-shell/console supplies its router-aware, basename-aware navigate, absent-seam behavior unchanged. ⛔ No router sniffing, no react-router peer requirement, nonavigateOnSuccesschanges (that key is #5034, blocked on this landing).
Routing authority: maintainer ruling, 2026-08-17 「同意」 batch (comment above).Label
pm:queue→pm:dispatchedin the same stroke.
Generated by Claude Code
{ "issue": 4989, "status": "done", "branch": "claude/issue-4989-injected-navigation", "pr": "https://github.com/objectstack-ai/objectui/pull/5111", "premise_still_valid": true, "summary": "Defect 4 (mount-blindness) implemented as the maintainer ruled it on 2026-08-17: an optional injected navigation seam. `HostNavigationContext` in `@object-ui/react` (a host offers its router-aware navigate; absent answers `{navigate: undefined}` and never throws); `submitRedirectNavigation.ts` in plugin-form owns the delayed leg for BOTH redirect call sites and travels through the host's navigate when present, `window.location.assign` when not; `HostNavigationBridge` mounted in app-shell's `ConsoleShell` supplies React Router's navigate, so the deployment basename is applied. Context over prop because several of these forms arrive through ComponentRegistry/SchemaRenderer where the shell never constructs the element (a prop-only seam would silently skip schema-rendered forms), and because `DrillNavigationContext` is the same package's existing precedent for host-provided navigation. One judgement call flagged for review in the PR body: the bridge supplies nothing rather than throwing when no router is above it, because `ConsoleShell` is rendered router-less by an existing test and making it crash would be an unasked-for tightening.", "tests": "All at f7aa041 (branch head; union run AFTER the final commit). `pnpm exec vitest run packages/plugin-form/src/ packages/app-shell/src/console/ packages/react/src/` -> `Test Files 151 passed (151) | Tests 1525 passed (1525)`. `pnpm --workspace-concurrency=2 --filter @object-ui/plugin-form --filter @object-ui/react --filter @object-ui/app-shell type-check` -> all three `Done` (each runs `tsc --noEmit && tsc -p tsconfig.test.json`, so the new tests are type-checked); same filter `lint` -> `0 errors` in all three (308/597/2386 warnings = main's baseline). Gates: check:phantom-deps, check:self-import, check:control-bytes, check-changeset-presence (`14 source file(s) of 3 released package(s) changed, 1 changeset`), check-changeset-no-major -> all green. Dependency closure built first (`pnpm --filter '@object-ui/app-shell^...' build`); without it type-check reports TS6305 against unbuilt dist/*.d.ts and reads like a real failure. New coverage: submitRedirect.injectedNavigation.test.tsx (12, replaces submitRedirect.mountBlind.test.tsx as that file's own docblock required), HostNavigationBridge.test.tsx (5, against a REAL router: `/thanks` under basename `/_console` lands at `/_console/thanks` in window.location), ConsoleShell.hostNavigation.test.tsx (2). REVERSE VERIFICATION, measured: (1) seam ablated (hook always calls window.location.assign) -> 5 of 12 red, exactly the cases asserting a host navigate received the destination; I had PREDICTED 8 and the docblock now carries the measured number plus which survivors are not change detectors (the delay/unmount cases assert nothing was called, true under both bodies); both pre-existing component redirect files stay entirely green, since they mount no provider. (2) supplier ablated (the HostNavigationBridge line deleted from ConsoleShell) -> 2 red, both in ConsoleShell.hostNavigation.test.tsx, 0 anywhere else. That second ablation was run BEFORE the file existed and found a real gap: deleting one line restored the whole mount-blind behaviour with every test in the repo still green, which is why the supplier pin was added.", "open_questions": [], "out_of_scope_findings": [ "filed as #5112: EmbeddableForm's thank-you redirect (`thankYouPage.redirectUrl`, EmbeddableForm.tsx:262-268 via `isRedirectUrlSafe`) is mount-blind the same way — a same-origin RELATIVE destination is admitted and then travelled to with `window.location.href`, leaving a sub-path-mounted host. Not folded in: different key, deliberately more permissive contract (same-origin or an allowedHosts allowlist, because a public form's thank-you page is often genuinely external), so the arm needs splitting relative-vs-external rather than the seam applied wholesale. Filed unassigned, unlabeled for PM triage.", "no issue filed, fixed in-PR as part of my own wiring: nothing pinned that `ConsoleShell` MOUNTS the bridge — see reverse verification (2). A seam with no supplier changes nothing, and the supplier was unwatched until `ConsoleShell.hostNavigation.test.tsx` was added." ] }Draft PR #5111 is open and NOT marked ready; auto-merge not enabled. CI gate conclusions are the PM's to read — reporting at draft-PR time per the dispatch contract.
Generated by Claude Code
- added 3 commits that reference this issue
on Aug 23, 2026
Found while implementing objectui#4190 (the console
FormPagehalf of the same ruling). Filed unassigned, NOT fixed there — #4190's scope isapps/console, and this surface is a published renderer package with a different routing situation, so it needs its own decision about mechanism.What
packages/plugin-formconsumessubmitBehavior: { kind: 'redirect' }at two sites, both with the same shape:src/ObjectForm.tsx:803-805src/WizardForm.tsx:490-492os#7496 (ruled 2026-08-11, landed by os#7657, live on this repo's
@objectstack/spec17.0.0 GA pin) settled what this value means: relative paths only, interpolation only from declared record fields spelled{{record.field_name}}, every interpolated value URL-escaped when the redirect is built. Measured against that, these two sites diverge four ways.1. It is explicitly NOT an open redirect — stating this first so the issue is not over-graded.
isSameOriginUrl(src/successBehavior.ts:19-27) resolves the value againstwindow.location.hrefand compares origins, so a cross-origin destination never reaches the navigation. The open-redirect face is already closed here.2. Out-of-contract values are SILENTLY DROPPED. When the guard answers false, the
ifsimply does not fire: no toast, no error, no confirmation panel, no navigation. The write already succeeded, so the submitter is left looking at a still-filled form with zero feedback about what happened — and the obvious next move is to submit again, which writes a second record. Silence is the one outcome the consumer side of the ruling rules out ("neither silently dropped nor followed").3. A same-origin ABSOLUTE url is followed, where the contract says relative-only.
isSameOriginUrl('https://own-host/thanks')is true, so this consumer accepts a spelling the spec now refuses at the authoring door. More permissive than the contract, i.e. metadata that cannot be published still works here — which is how a rejected spelling stays alive in a corpus.4. Mount-blindness, the same class as objectui#4190/#4181. A full-page navigation does not see React Router's
basename, so a ruled in-app path such as/thanksresolves against the ORIGIN root. Under a mounted host (the framework CLI configures one for every embedded deployment) that leaves the app. This is the half that needs a decision rather than a patch:plugin-formis a component library that can be rendered outside a router, so it cannot simply calluseNavigate()the wayapps/console'sFormPagenow does. Options include an injected navigate callback, a router-presence check, or declaring the router a peer requirement of these two components — I have no measurement to pick between them and did not guess.5. Tokens are never substituted, so an authored
/thanks?ref={{record.id}}— a value the contract accepts — navigates with the literal braces in the query.Note for whoever picks this up:
navigateOnSuccess(ObjectForm.tsx:826-828,WizardForm.tsx:520-523, viaresolveSuccessNavigate) is a different declared key with its own{id}/{recordId}interpolation dialect. It is same-origin-guarded the same way and shares defects 2 and 4, but it is a separate contract question and is only mentioned here so the next reader does not conflate the two.Why this was not folded into objectui#4190's PR
Prime Directive #10 — #4190 is scoped to
apps/console/src/components/FormPage.tsxby its card, its triage chain and its dispatch. Fixing a published package's navigation behaviour in that PR would widen it past the ruling's console half and past the mechanism decision noted in point 4.Reachability, honestly: needs a form authoring a
redirectbehaviour, and for defect 4 a mounted host as well. Defects 2 and 3 need only the authored redirect, and defect 2 has user-visible consequences (duplicate submissions) with no mount involved.Generated by Claude Code