Skip to content

fix(plugin-dashboard): a url header action goes through the action runner, and an omitted actionType runs as url (objectui#12275) - #12284

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12275-dashboard-header-action-runner
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12275-dashboard-header-action-runner

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12275
Clause-②: no

A dashboard header action of type url, or one with no actionType, 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 reached classifyNavigationTarget, 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 by domain:ui seat 1, session https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z.

What changed

All in packages/plugin-dashboard/src/DashboardRenderer.tsx:

  • The click dispatches the def headerActionDefs builds, whatever its type. The actionType === 'url' branch is deleted, together with its ^https?:// regex, its leading-// test, location.assign and the unconditional pushState. So is the guard that dropped an omitted actionType with "Header action missing actionType/actionUrl". The def's type: actionType || 'url' is the one reading of an omitted type, as the contract door reads it. The click never re-reads the authored actionType. The file holds no scheme test.
  • The runner's redirect hand-off is followed for a url action. A runner with no navigation handler (no ActionProvider above 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 same history.pushState + popstate it used before, so a provider-less embed still navigates in the app. pushState cannot leave the origin, and the runner sets redirect here only for a target it classified as an in-app path.
  • A url header action raises no success toast. The def carries toast: { showOnSuccess: false } for url, 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/core is read and reused, not changed. The runner already provides everything this needs.

Measurements (dispatch Zone 2)

  1. The handler, at 9f282ef: confirmed as the dispatch describes it. An omitted actionType returned early with console.warn('[DashboardRenderer] Header action missing actionType/actionUrl:', …). For url, the handler called location.assign on a ^https?:// match or a leading //, else history.pushState + popstate. Every other type went through executeAction. headerActionDefs built type: actionType || 'url'.
  2. What the runner does with each target, measured by the new pins on a real render with the real runner:
    • A refused target (//cdn.example.com/x, thanks): the runner answers { success: false, error: NAVIGATION_TARGET_REFUSAL }. Under an ActionProvider with onToast, as the console's GlobalActionRuntimeProvider wires 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 failed with 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.href is set once, in place. There is no router call, no new tab and no throw.
    • An in-app /apps/... path: under a provider it goes to the host's navigation handler with external: false (react-router navigate in the console). With no provider the runner returns it as redirect, and the click follows it with pushState + popstate. That is the old pushState branch's behaviour, kept.
    • https://: under a provider it goes to the navigation handler with external: true, newTab: true, and the console opens it in a new tab. With no provider the runner calls window.open(URL, '_blank', 'noopener,noreferrer').
  3. Nothing the runner lacks. No change to @object-ui/core. The only addition on the dashboard side is following the runner's existing no-handler redirect hand-off, the same one the html-tier anchor follows.

Published surface

  • No export, prop, type member, spec key or i18n key moves. The edit sits inside a useMemo callback and an inline click closure of DashboardRendererInner. No declaration the package emits changes, so the package's .d.ts cannot move. That is a reading of the diff, not a dist byte comparison.
  • The header action click's runtime accept set moves onto the contract's own set (navigationTargetRefusal, read by the claim's Clause-② reason):
    • Narrowed: a protocol-relative //host is refused; it used to be opened with location.assign. A value that is not http://, https://, mailto:, /… or ./… is refused; it used to be pushed as a route. That covers thanks, and also ?tab=x, #frag and any other scheme.
    • Admitted, as the contract already admits: mailto:, which used to throw in pushState, and an omitted actionType, which used to be dropped.
    • Nothing outside the contract's set is admitted, so Clause-②: no.
  • Behaviour that now follows the runner's conventions for every url action (for the reviewer, none of it widens the set):
    • An https:// target opens in a new tab under the console. It used to replace the current page.
    • A ${…} expression in actionUrl is now interpolated and evaluated, as it is on every runner url target.
    • An /api/, /_auth/ or /_account/ path takes the runner's full-page navigation and its apiBase prefix.

Tests

All at e89a1e9 (this branch's head).

  • New pins: packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActionUrl-12275.test.tsx. They run on a real DashboardRenderer with the real runner, twice: under an ActionProvider wired with onNavigate and onToast as the console wires it, and with no provider.
    • The card's four pins: //cdn.example.com/x is refused at the click; mailto: opens; a bare relative value is refused; an omitted-type header action with an https:// target navigates.
    • The fifth pin: an in-app /apps/... path still navigates in the app.
    • "Refused" is read off the runner, not off prose: the dashboard surfaces exactly the error a bare runner answers for the same target, that answer is a failure, and no sink (router, location.href, window.open, pushState) is touched.
  • Fixture triage: DashboardRenderer.headerActions.test.tsx had a case named "still navigates for a url header action instead of dispatching". It pinned the bypass this card removes (a host url handler NOT called). It is replaced, not re-spelled: url joins the it.each of types that must reach their handler.
  • Ablation, one leg, at e89a1e9:
    • Mutation: DashboardRenderer.tsx was checked out from the base 9f282ef. 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.
    • Result: the two header-action files gave Tests 10 failed | 10 passed (20), the expected direction. All nine new-file pins that the fix changes went red, plus the url row of the existing it.each.
    • What stayed green: the no-provider in-app path pin (a CONTROL: the old pushState branch did the same thing), and the nine other rows of the existing file.
    • Restore: from HEAD. The blob was confirmed equal to the HEAD blob (bcd4a5fd…), git diff HEAD was empty, and a trap guarded the restore.
  • Whole package: pnpm exec vitest run --maxWorkers=2 packages/plugin-dashboard/ gave Test Files 176 passed (176), Tests 1759 passed | 6 skipped (1765).
  • Importers that render the dashboard: the five packages/app-shell/src/views/DashboardView.*.test.tsx suites, plus examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx and plugin-dashboard-component-schema.test.ts. Result: Test Files 7 passed (7), Tests 69 passed (69).
  • Type-check: pnpm --filter @object-ui/plugin-dashboard type-check echoed tsc --noEmit && tsc -p tsconfig.test.json and exited 0. This ran after turbo run build --filter=@object-ui/plugin-dashboard^... (12 tasks successful). tsc -p tsconfig.test.json --listFilesOnly lists both header-action test files.
  • Gates, all exit 0:
    • 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-parity and check:action-ref-convention: all OK.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 3 touched .ts / .tsx files reported 3 files, 0 errors and 8 warnings. All 8 are in DashboardRenderer.tsx, and the base blob linted the same way gives the same 8 rules (6 no-explicit-any, 2 exhaustive-deps), shifted only by the inserted lines. The new and edited test files have 0. Why the narrowing excludes nothing:
    1. The population comes from eslint.config.js, whose files: ['**/*.{ts,tsx}'] holds all three.
    2. The count, 3, is read from the JSON output.
    3. The config has no type-aware linting (no parserOptions.project or projectService), and no local rule in eslint-rules/ reads the filesystem. So this diff cannot move a verdict on an untouched file.
  • NOT MEASURED, left to CI:
    • pnpm lint over the tree.
    • The full @object-ui/app-shell suite. 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

  • form and the objectui-local navigation type in a provider-less host. The runner hands their route back as redirect too, and this click follows the hand-off for url only, as the card scopes it. Such a header action in an embed with no ActionProvider still does not navigate, as before this PR. The console always mounts a provider. Read from source, not reproduced. Not filed; carrier: none.
  • Two header actions with one actionUrl register under one engine name, so the second def replaces the first and both buttons run the second. This was already true of every non-url type and is unchanged here. Read from source. Not filed; carrier: none.
  • modalHandler / scriptHandlers are installed on engine.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.
  • Docs: no page under content/docs and no package README describes how a dashboard header url action navigates (searched for actionUrl, actionType and header-action wording), so none became false.
  • Changeset: .changeset/12275-dashboard-header-action-runner.md, @object-ui/plugin-dashboard patch.

Generated by Claude Code

…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>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3131.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-B6zRgM29.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.51KB 141.71KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 280.00KB 71.39KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 254.49KB 67.01KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.36KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 255.09KB 70.91KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.35KB 31.54KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12284 at e89a1e9 · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T00:23Z

Verdict: ACCEPT. Reviewed on GitHub against the claim 6134917028 and triage 6134181226, not against the report 6135173159. It is gated on the at-tier contract review that Clause-②'s narrowing limb owes: the click now refuses //host and a bare relative value. In this act the seat hangs needs:contract-review on the PR, its own act at ACCEPT. The dev's label write was refused by its environment before any request was sent, and was not retried. The review runs once CI on this head has converged.

PR form.

  • Draft, base main, assignee zhuangjianguo. Line 1 is Fixes #12275 and line 2 is Clause-②: no; a scan of the whole body found no other closing verb next to a card number.
  • check-governed-merges --pr reads NOT governed, 339 lines. One commit; its trailers follow objectui AGENTS.md.

The diff, read against the runner at this head.

  • One path for every header action. The click dispatches the def that headerActionDefs builds. The url branch is deleted (^https?://, the leading-// test, location.assign, the unconditional pushState), and so is the guard that dropped an omitted actionType. type: actionType || 'url' is the one reading of an omitted type. A grep of the file at e89a1e9 finds no scheme test outside comments.
  • The index lines up. headerActions (the buttons) and headerActionDefs (the defs) both map schema.header.actions, so headerActionDefs[i] is the def of button i.
  • The runner decides, as navigateTo reads at this head. classifyNavigationTarget answers null ⇒ { success: false, error: NAVIGATION_TARGET_REFUSAL }. mailto: ⇒ location.href, in place. With a navigation handler, every admitted target goes to it. Without one, a web target goes to window.open (new tab) and a path target comes back as redirect.
  • The redirect follow is the old pushState branch, kept. redirect reaches the click only for a target the runner classified path, so pushState stays same-origin, and the follow is scoped to url as the card scopes it.
  • The toast switch. The runner reads action.toast ?? { showOnSuccess: true, showOnError: true }. { showOnSuccess: false } therefore leaves showOnError unset, so the refusal's error toast still fires. The pins assert it with the console's onToast wiring.
  • The spec requires actionUrl, so the deleted guard's other half (no actionUrl) is outside the contract. Such an action now warns action failed, not missing actionType/actionUrl; both are a console warning.

Accepted deviations. Both are inside the fences: no new prop, key, export or i18n key, and no scheme list or URL grammar.

  • toast: { showOnSuccess: false } on a url def. The click raised no success toast before; the html-tier anchor suppresses it the same way.
  • The click follows the runner's redirect hand-off for url only, so a provider-less embed still navigates to an in-app path.

Behaviour that moves to the runner's conventions. Each is named in the PR and the changeset, and none widens the contract's set. This is what "goes through the ActionRunner, as every non-url header action already does" means.

  • https:// opens in a new tab under the console (it replaced the page before).
  • ${…} in actionUrl is interpolated.
  • /api/, /_auth/ and /_account/ paths take the runner's full-page navigation.

The changeset, sentence by sentence. @object-ui/plugin-dashboard patch.

  • "//host … is refused at the click", "a bare relative value such as thanks is refused too", "mailto: … opens the mail client in place", "A header action with no actionType runs as a url action" and "An https:// target opens the way every runner url action opens one": each is true of the diff and the runner above.
  • "An in-app path … still navigates in the app … with no action provider, by the same history push as before": true.
  • "Following a url header action raises no success toast, as before" and "Nothing is added to the package entry": the at-tier review re-reads the last one.

Evidence, as reported.

  • Ten pins on a real DashboardRenderer with the real runner: the card's four plus an in-app path, each under a console-wired ActionProvider and with none. "Refused" is read off a bare runner's answer for the same target.
  • The old case "still navigates for a url header action instead of dispatching" pinned the bypass. It is replaced: url joins the it.each of types that must reach their handler.
  • Ablation (the renderer at base 9f282ef) turns 10 of 20 red. The provider-less in-app pin stays green, as a control.
  • The whole package passed: 176 files and 1759 tests. Seven importer suites also passed.
  • The three out-of-scope notes (form / navigation in a provider-less host, two actions sharing one actionUrl, handlers left on the shared runner) are pre-existing, read from source and not reproduced. They are Acceptance notes with no carrier.

Next (the seat's).

  • CI on e89a1e9 is converging.
  • Then an isolated at-tier contract review renders its record on this head.
  • On PASS: strip needs:contract-review, then pr_ready + automerge_enable.

Implemented-by: claude/issue-12275-dashboard-header-action-runner (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e89a1e9feae7cf6d7dc10915d762c720fd219bda
Local-runs: none
Written: 2026-10-13T00:38Z

Inputs: card #12275 (body, triage 6134181226, claim 6134917028, os-dev-report 6135173159), PR #12284 (body, 4-file list, net diff against main), the 33 check-runs on the head, and at the head: packages/core/src/actions/ActionRunner.ts (execute, executeUrl, navigateTo, handlePostExecution, interpolateTarget), packages/core/src/actions/navigationTarget.ts, packages/core/src/actions/ActionEngine.ts (executeAction), packages/react/src/hooks/useActionEngine.ts, packages/react/src/context/ActionContext.tsx, packages/app-shell/src/hooks/useConsoleActionRuntime.tsx (navigateHandler), packages/components/src/renderers/basic/html-elements.tsx (the anchor precedent), packages/plugin-dashboard/src/index.tsx and package.json, .changeset/config.json, objectui AGENTS.md §9. The contract set, from objectstack main: packages/spec/src/ui/navigation-target.ts (navigationTargetRefusal, ADMITTED_SCHEMES), packages/spec/src/ui/dashboard.zod.ts (DashboardHeaderActionSchema), packages/spec/src/ui/action.zod.ts (ActionType), packages/lint/src/validate-dashboard-action-refs.ts.

Gate verdicts, read off the head's check-runs: 33 runs, all completed; every non-skipped one success (Lint, Type Check, Test shards 1–12, Test, Test dist pins, Build & E2E, Build Docs, Bundle Analysis, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report, Repo Gates, Spec Main Shape Gate, Governed Surface Queue Guard, Line Citation Gate, label, Live E2E informational); 3 skipped (Test coverage, Test coverage shard, dependabot). No gate family is re-run here.

① Derived judgments

The diff changes one source file, packages/plugin-dashboard/src/DashboardRenderer.tsx, inside DashboardRendererInner: the headerActionDefs useMemo and the header button's handleClick closure. The click now calls executeAction(action.actionUrl) for every header action; the actionType === 'url' branch (its ^https?:// regex, its leading-// test, location.assign, the unconditional pushState) and the !actionType || !actionUrl guard are gone. The file holds no scheme test. Each accept-set move the diff implies, judged against navigationTargetRefusal (contract text) and classifyNavigationTarget (the runtime's one classifier, ruling A+ on #12191):

  1. Protocol-relative //host: refused. classifyNavigationTarget answers null (url[1] !== '/'), navigateTo returns { success: false, error: NAVIGATION_TARGET_REFUSAL }, no sink is touched. Used to be location.assign. The contract refuses it ("opens with //, which is protocol-relative"). Right — a narrowing onto the text.
  2. / then a backslash, and a target holding a tab, LF or CR: refused. Both classifier and contract refuse them for the same reason (the parser reads them as //host). Right.
  3. A bare relative value (thanks, ../x, ?tab=x, #frag), https:example.com without //, and every other scheme (javascript:, data:, tel:): refused. Used to be pushed as a route. The contract refuses each ("opens with neither an admitted scheme nor / or ./"; "names no host"; "a scheme a url target never opens"). Right.
  4. mailto:: admitted, opens in place. navigateTo kind 'mail' sets window.location.href and returns success; no handler, no tab, no router. Used to throw in pushState. The contract admits mailto: (ADMITTED_SCHEMES). Right — a refusal the contract denies, removed.
  5. http:// / https://, scheme compared case-insensitively: admitted. Under a provider the runner calls the host's handler with external: true, newTab: true (newTab = source.newTab ?? isExternal; the header action carries no openIn), and the console's navigateHandler opens window.open(url, '_blank', 'noopener,noreferrer'); with no provider the runner opens the tab itself. Used to replace the page for the lower-case form, and to push an upper-case HTTPS:// as a route. The contract admits the web schemes and leaves the tab to the caller's openIn; the header action declares none, so the runner's default applies. Right. The same-tab to new-tab move admits no new input: it is the runner's one convention for every url target, which ruling A+ makes the fulfilment of the contract. Not a widening.
  6. An in-app /… or ./… path: admitted. Under a provider it goes to the host router (external: false); with no navigation handler navigateTo answers { success: true, redirect: url } — only for a target it classified, and only when newTab is false — and the click follows that with history.pushState + popstate, gated on headerActionDefs[i]?.type === 'url' (index-aligned with the same schema.header.actions array). The contract admits the path. Right. pushState cannot leave the origin, so the follow adds no reach beyond the runner's own answer.
  7. /api/, /_auth/, /_account/ paths: admitted as paths; the runner takes window.location.href with the apiBase prefix. The contract admits them as in-app paths and says nothing about the mechanism; the mechanism is the runner's. Right — no accept-set move.
  8. ${…} in actionUrl: interpolated (param / ctx scopes, URL-encoded) and evaluated, then classified. Used to be pushed literally. The contract text admits "a template that opens with ${" and says its value is judged "at click, where the runner judges the interpolated string against this same set". Right — within the contract's set, not a widening; the runner's judgment is the one the contract defers to.
  9. An omitted actionType runs as url. The def's type: actionType || 'url' is the one reading; the click never re-reads the authored type. The contract declares actionType: WidgetActionTypeSchema.optional() and validateDashboardActionRefs reads a missing type as 'url' ("Renderer default: a missing actionType is treated as a 'url' navigation"). Used to be dropped with a warning. Right — a refusal the contract denies, removed.
  10. An empty actionUrl (''): refused. The def registers under label, ActionEngine.executeAction('') answers Action not found before the runner runs, and the click warns and returns; no toast reaches the user. The contract's set refuses '' (bare relative), so the direction is right; the reason text is the engine's, not the runner's. Right in direction; noted in ③, not a breach.
  11. Non-url types: dispatch unchanged. They went through executeAction before and do now; the redirect follow does not apply to them. A host-registered url handler (ActionProvider handlers) still wins over the builtin, as execute consults this.handlers before builtinExecutors — the re-spelled it.each row in DashboardRenderer.headerActions.test.tsx relies on that existing runner behaviour. Right.

Toast. The url def carries toast: { showOnSuccess: false }. toast is an existing ActionDef field read by handlePostExecution (showToast.showOnSuccess !== false); the error toast stays (showOnError default), so a refusal under the console is reported with the runner's NAVIGATION_TARGET_REFUSAL. The html-tier anchor sets the same field on the same action shape (execute({ type: 'url', target: path, toast: { showOnSuccess: false } })). Right — a runtime choice on an internally built def, not a surface.

Published surface of @object-ui/plugin-dashboard, judged from src/index.tsx and package.json exports at the head (no build available). exports is the single . entry (types / import / require), unchanged. index.tsx's export list (DashboardRenderer, DashboardGridLayout, the widgets, dashboardComponents, the config-panel translations, the dataset-catalog and metric-node types, the retired-node tombstones) and its ComponentRegistry.register('dashboard', …) inputs are untouched by the diff. DashboardRendererProps is untouched. No export, prop, type member, spec key or i18n key moves; no other package's source changes (@object-ui/core is reused). Right.

Tests. The removed case "still navigates for a url header action instead of dispatching" pinned the bypass (a host url handler NOT called) and is replaced by the url row of the it.each, not re-spelled. The new file pins the card's four plus the in-app path, under a provider wired as the console wires it and with none; refusal is read off a bare ActionRunner's answer for the same target, and no sink (router, location.href, window.open, pushState) is touched on a refusal. Right. The ablation is the dev's report; the check-runs are the verdict here.

② Semver level

Changeset .changeset/12275-dashboard-header-action-runner.md: '@object-ui/plugin-dashboard': patch. The package is in the fixed group, so the group moves patch. objectui AGENTS.md §9 forbids major in any changeset (check-changeset-no-major; the Changeset Bump Policy and Fixed Group checks are green on the head). skip-changeset would be wrong: a released package's src/ changed.

patch is the right level. No published type, export, prop, spec key or i18n key moves; the accepted input (DashboardHeaderActionSchema: label, actionUrl: string, actionType?: ActionType, icon?) is as it was. The click's runtime accept set moves onto the contract's own set in both directions — refusing //host, the backslash and control-character spellings, bare relative values and foreign schemes (which the contract refuses), admitting mailto: and an omitted type (which the contract admits). That is the runtime fulfilling the one stated grammar (ruling A+), not a narrowing or widening of the published contract, so neither arm applies. The behaviour that now follows the runner's conventions for the same admitted inputs — a new tab for https://, ${…} interpolation, full-page navigation for /api/-class paths, an error toast on refusal — is user-visible and is stated in the changeset body, which a patch carries. The alternative reading (an author's working thanks or //cdn… now refuses, hence breaking) does not hold: the contract text and classifyNavigationTarget already refused those for every other url target on main, and this handler was the one outlier the ruling named.

Clause-②: no

③ Boundary flags

  • Dev deviation 1 — label-write refused by the classifier; the seat must hang needs:contract-review. Answered: the PR's labels read plugin, tests, needs:contract-review at review time. Nothing further.
  • Dev deviation 2 — the url def's toast: { showOnSuccess: false }, and the pushState + popstate follow of result.redirect for a url def only; "seat's call whether either should go". Answered: neither should go. The toast field is judged in ① (existing ActionDef field, anchor precedent, error toast kept). The follow reads the runner's classified answer, holds no grammar of its own, is reached only when the runner had no navigation handler and classified a path, and pushState cannot leave the origin; the anchor precedent follows the same hand-off (with location.assign), and the dashboard keeps its former SPA mechanism for the provider-less embed. Both sit inside the card's fences (no scheme list, no new prop, key or export).
  • Dev deviation 3 — commit trailers in the model-free pair, PR footer in the session-URL form. Commits are not an input of this record; the PR body names no model. Not a contract matter. Noted.
  • open_questions: empty. Nothing to answer.
  • Out-of-scope findings (carrier: none, three): a provider-less form / objectui-local navigation header action still does not follow the redirect hand-off (pre-existing; the console always mounts a provider); two header actions sharing one actionUrl register under one engine name (pre-existing for every type); modalHandler / scriptHandlers are installed on the shared provider runner (pre-existing). Each is outside the card's scope and unchanged by the diff; this record files nothing, and the seat may.
  • Reviewer's own flags, none escalated: (a) objectstack#23101, the spec-side door that applies navigationTargetRefusal to actionUrl, is still open; the runtime side does not wait on it (triage: not blocked), and when it lands the door and the click refuse the same set, so nothing here changes. (b) An empty actionUrl is refused with the engine's Action not found and no toast (① item 10); direction consistent with the contract, reason text differs from the runner's — a note, not a breach. (c) The refusal text a console viewer sees is the runner's existing English NAVIGATION_TARGET_REFUSAL; no new i18n surface is introduced by this diff.

Implemented-by: claude/issue-12275-dashboard-header-action-runner
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

needs:contract-review removed by the dispatching seat (domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z) · 2026-10-13T00:39Z.

  • Record: ## Contract review comment 6135411972 on this PR. Served tier CONTRACT_REVIEW_TIER, judged head e89a1e9feae7cf6d7dc10915d762c720fd219bda (the current head), Local-runs: none, independence pair claude/issue-12275-dashboard-header-action-runner / session_01CsyMRsFJBKNEMa4oPY1b5z, VERDICT: PASS, nothing escalated.
  • At this head: 33 check-runs, each success or an expected skip (dependabot, the two Test (coverage) jobs); mergeable_state clean; check-governed-merges --pr NOT governed, 339 lines.
  • The seat's ACCEPT is 6135219038 on this PR. Landing follows in the same act: pr_ready + automerge_enable (the merge queue).

Generated by Claude Code

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

Projects

None yet

2 participants