Repository navigation
fix(plugin-dashboard): a url header action goes through the action runner, and an omitted actionType runs as url (objectui#12275) - #12284
Conversation
…nner, and an omitted actionType runs as url (objectui#12275) The header action click kept a url grammar of its own: `location.assign` for `^https?://` or a leading `//`, `history.pushState` for anything else. So a protocol-relative `//host` was opened, `mailto:` threw, a bare relative value was pushed as a route, and an omitted `actionType` was dropped with a warning although `headerActionDefs` reads it as `url`. The click now dispatches the def `headerActionDefs` builds, every type alike, so the runner's `classifyNavigationTarget` decides what a url target may open (ruling A+ on objectui#12191). With no navigation handler the runner hands an admitted in-app path back as `redirect`; the click follows that hand-off with the same history push it used before. A url header action raises no success toast, as before. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Seat ACCEPT: objectui PR #12284 at
|
Contract reviewServed-tier: Inputs: card #12275 (body, triage Gate verdicts, read off the head's check-runs: 33 runs, all ① Derived judgmentsThe diff changes one source file,
Toast. The Published surface of Tests. The removed case "still navigates for a url header action instead of dispatching" pinned the bypass (a host ② Semver levelChangeset
Clause-②: no ③ Boundary flags
Implemented-by: VERDICT: PASS |
|
Generated by Claude Code |
Fixes #12275
Clause-②: no
A dashboard header action of type
url, or one with noactionType, now goes through the ActionRunner like every other header action. The click used to navigate with a url check of its own, so it never reachedclassifyNavigationTarget, the runner's one answer to what a click may open (ruling A+ on objectui#12191, comment 6121792024). This PR follows the triage direction (comment 6134181226) and the seat's claim (comment 6134917028). Dispatched bydomain:uiseat 1, sessionhttps://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z.What changed
All in
packages/plugin-dashboard/src/DashboardRenderer.tsx:headerActionDefsbuilds, whatever its type. TheactionType === 'url'branch is deleted, together with its^https?://regex, its leading-//test,location.assignand the unconditionalpushState. So is the guard that dropped an omittedactionTypewith "Header action missing actionType/actionUrl". The def'stype: actionType || 'url'is the one reading of an omitted type, as the contract door reads it. The click never re-reads the authoredactionType. The file holds no scheme test.redirecthand-off is followed for aurlaction. A runner with no navigation handler (noActionProviderabove the dashboard) does not navigate to an admitted in-app path. It returns{ success: true, redirect: PATH }. The click follows that hand-off with the samehistory.pushState+popstateit used before, so a provider-less embed still navigates in the app.pushStatecannot leave the origin, and the runner setsredirecthere only for a target it classified as an in-app path.urlheader action raises no success toast. The def carriestoast: { showOnSuccess: false }forurl, so the console does not toast "completed successfully" after a navigation. It raised none before, and the html-tier anchor suppresses it the same way. The error toast stays, so a refusal is reported.@object-ui/coreis read and reused, not changed. The runner already provides everything this needs.Measurements (dispatch Zone 2)
9f282ef: confirmed as the dispatch describes it. An omittedactionTypereturned early withconsole.warn('[DashboardRenderer] Header action missing actionType/actionUrl:', …). Forurl, the handler calledlocation.assignon a^https?://match or a leading//, elsehistory.pushState+popstate. Every other type went throughexecuteAction.headerActionDefsbuilttype: actionType || 'url'.//cdn.example.com/x,thanks): the runner answers{ success: false, error: NAVIGATION_TARGET_REFUSAL }. Under anActionProviderwithonToast, as the console'sGlobalActionRuntimeProviderwires it, that is an error toast carrying the runner's refusal. With no provider there is no toast handler, and the dashboard logs[DashboardRenderer] action failedwith the same error, as it does for every other header action. It is never a silent no-op, and no navigation sink is touched.mailto::window.location.hrefis set once, in place. There is no router call, no new tab and no throw./apps/...path: under a provider it goes to the host's navigation handler withexternal: false(react-routernavigatein the console). With no provider the runner returns it asredirect, and the click follows it withpushState+popstate. That is the oldpushStatebranch's behaviour, kept.https://: under a provider it goes to the navigation handler withexternal: true, newTab: true, and the console opens it in a new tab. With no provider the runner callswindow.open(URL, '_blank', 'noopener,noreferrer').@object-ui/core. The only addition on the dashboard side is following the runner's existing no-handlerredirecthand-off, the same one the html-tier anchor follows.Published surface
useMemocallback and an inline click closure ofDashboardRendererInner. No declaration the package emits changes, so the package's.d.tscannot move. That is a reading of the diff, not a dist byte comparison.navigationTargetRefusal, read by the claim'sClause-② reason)://hostis refused; it used to be opened withlocation.assign. A value that is nothttp://,https://,mailto:,/…or./…is refused; it used to be pushed as a route. That coversthanks, and also?tab=x,#fragand any other scheme.mailto:, which used to throw inpushState, and an omittedactionType, which used to be dropped.Clause-②: no.urlaction (for the reviewer, none of it widens the set):https://target opens in a new tab under the console. It used to replace the current page.${…}expression inactionUrlis now interpolated and evaluated, as it is on every runnerurltarget./api/,/_auth/or/_account/path takes the runner's full-page navigation and itsapiBaseprefix.Tests
All at
e89a1e9(this branch's head).packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActionUrl-12275.test.tsx. They run on a realDashboardRendererwith the real runner, twice: under anActionProviderwired withonNavigateandonToastas the console wires it, and with no provider.//cdn.example.com/xis refused at the click;mailto:opens; a bare relative value is refused; an omitted-type header action with anhttps://target navigates./apps/...path still navigates in the app.location.href,window.open,pushState) is touched.DashboardRenderer.headerActions.test.tsxhad a case named "still navigates for a url header action instead of dispatching". It pinned the bypass this card removes (a hosturlhandler NOT called). It is replaced, not re-spelled:urljoins theit.eachof types that must reach their handler.e89a1e9:DashboardRenderer.tsxwas checked out from the base9f282ef. Landing was confirmed by blob hash (3a7628c5…, equal to the base blob) and by marker counts:window.location.assign(actionUrl)went 0 to 1, and the redirect follow went 1 to 0.Tests 10 failed | 10 passed (20), the expected direction. All nine new-file pins that the fix changes went red, plus theurlrow of the existingit.each.pushStatebranch did the same thing), and the nine other rows of the existing file.HEAD. The blob was confirmed equal to the HEAD blob (bcd4a5fd…),git diff HEADwas empty, and a trap guarded the restore.pnpm exec vitest run --maxWorkers=2 packages/plugin-dashboard/gaveTest Files 176 passed (176),Tests 1759 passed | 6 skipped (1765).packages/app-shell/src/views/DashboardView.*.test.tsxsuites, plusexamples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsxandplugin-dashboard-component-schema.test.ts. Result:Test Files 7 passed (7),Tests 69 passed (69).pnpm --filter @object-ui/plugin-dashboard type-checkechoedtsc --noEmit && tsc -p tsconfig.test.jsonand exited 0. This ran afterturbo run build --filter=@object-ui/plugin-dashboard^...(12 tasks successful).tsc -p tsconfig.test.json --listFilesOnlylists both header-action test files.check-changeset-presence: "3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)".check:new-line-citations: "VERDICT new-cross-file-line-citations: 0 new citation(s)".check:control-bytes: OK.check:changeset-claims,check:pending-changeset-literals,check:test-path-roots,check:vi-mock-specifiers,check:handler-key-reads,check:action-forward-parityandcheck:action-ref-convention: all OK.eslint --no-inline-config --format jsonover the 3 touched.ts/.tsxfiles reported 3 files, 0 errors and 8 warnings. All 8 are inDashboardRenderer.tsx, and the base blob linted the same way gives the same 8 rules (6no-explicit-any, 2exhaustive-deps), shifted only by the inserted lines. The new and edited test files have 0. Why the narrowing excludes nothing:eslint.config.js, whosefiles: ['**/*.{ts,tsx}']holds all three.parserOptions.projectorprojectService), and no local rule ineslint-rules/reads the filesystem. So this diff cannot move a verdict on an untouched file.pnpm lintover the tree.@object-ui/app-shellsuite. This is a declared narrowing: every app-shell suite that renders the dashboard ran above.check:eager-closure: the diff adds no import, so no eager module edge moves.Acceptance notes
formand the objectui-localnavigationtype in a provider-less host. The runner hands their route back asredirecttoo, and this click follows the hand-off forurlonly, as the card scopes it. Such a header action in an embed with noActionProviderstill does not navigate, as before this PR. The console always mounts a provider. Read from source, not reproduced. Not filed; carrier: none.actionUrlregister under one engine name, so the second def replaces the first and both buttons run the second. This was already true of every non-urltype and is unchanged here. Read from source. Not filed; carrier: none.modalHandler/scriptHandlersare installed onengine.getRunner(). Under the console that is the shared provider runner, so a dashboard's handlers stay installed on it after the dashboard unmounts. This is unchanged here. Read from source, not measured. Not filed; carrier: none.content/docsand no package README describes how a dashboard headerurlaction navigates (searched foractionUrl,actionTypeand header-action wording), so none became false..changeset/12275-dashboard-header-action-runner.md,@object-ui/plugin-dashboardpatch.Generated by Claude Code