Skip to content

feat(types,layout,app-shell): consume the declared nav runAction slot; retire the private ?runAction= convention - #5354

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5216-nav-runaction-slot
Aug 19, 2026
Merged

feat(types,layout,app-shell): consume the declared nav runAction slot; retire the private ?runAction= convention#5354
os-support-ai merged 1 commit into
mainfrom
claude/issue-5216-nav-runaction-slot

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5216

Zone 0 — the premise gate, measured

The blocker objectstack#7253 merged 2026-08-10, pre-GA, so the declared slot was expected in the 17.0.0 pin. Measured against the installed package, with counter-probes through the same import:

@objectstack/spec (installed) : 17.0.0
import path : @objectstack/spec/ui -> ObjectNavItemSchema
PROBE runAction present : true
CONTROL+ recordId present : true
CONTROL+ objectName present : true
CONTROL- zzNotAKey present : false

The counter-probes discriminate: a known-present key reads true, a known-absent key reads false, so the true on runAction is a reading and not an artefact of a wrong import path. Premise valid — proceeded.

Two further measurements against the same pin, both of which changed the implementation:

  • ObjectNavItemSchemaacceptsrunAction together with recordId. The card's description of a parse-level exclusivity rule is not true of this pin, so the list-surface-only precedence below is load-bearing rather than merely defensive.
  • A misspelling is rejected with did-you-mean (runActionn produces unrecognized_keys ... "Did you mean ... runAction?"), and runAction: '' is accepted — hence the empty-string-is-absent handling.

What the private convention could express that the declared slot cannot

Nothing, on the nav path. Measured: the string was produced in exactly one place (CloudOnboardingNext.tsx, hand-concatenating onto a route from page metadata) and consumed in exactly one (EnvironmentListToolbar.tsx, comparing a bare 'runAction' literal against a hard-coded create_environment). It was a single-value private protocol between two files — strictly less expressive than the declared slot, which carries any action name on any object.

The one genuine gap is that the welcome CTA is not a nav item: it is a page widget whose target route arrives as page metadata, so no NavigationItem exists to hang a slot on. That is not a fork, because the slot's contribution is the param name and encoding, and the widget now takes both from it while sourcing the action name from its own declared page metadata (properties.createAction). One contract, two declared producers — not two contracts. No spec change is needed and none is made (Clause-2: no).

No compatibility shim. No producer outside this repo emits the string: cloud supplies the bare environmentsRoute property and the query was concatenated here, so producer and consumer both moved in this PR.

Changes

  • packages/typesNavigationItem.runAction, derived from the spec's object-nav variant per the objectstack#4171 的三处 inverted pin 在 spec 17.0.0-rc.1 翻转了 —— NavigationItem / FormField / ConditionalValidation 该做 burn-down 了 #3177 burn-down. Also declared in objectui's own nav Zod schema: that schema strips unknown keys, so before this an entry carrying a deep link validated clean through objectui validatewith the deep link discarded (the objectstack#4115 failure class).
  • packages/layoutNAV_RUN_ACTION_PARAM is the param name's one definition, sited with resolveHref, its only writer. resolveHref encodes the slot onto the list landings only (bare / named view / filters slice, joining with & where a query already exists). A recordId entry resolves to a record page, which has no list toolbar to answer it, so encoding there would spend a one-shot intent on a surface that never had the action.
  • packages/app-shelluseNavRunAction is the single implementation of read-once / consume-once. The deep link is now honoured on every object list, not just the environments list. urlParams registers the name by re-export, so the reserved-param collision check is complete without a second spelling.

Arming stays destructive-aware (#4123): the action must actually be present at list_toolbar. A name no action answers to runs nothing and deliberately leaves the URL intact, so a later mount with fresher metadata can still honour it — the loud rejection of an undefined reference already happened upstream at authoring time, where the author can act on it (defineStack: "deep-link references action '...' (via runAction)").

Tests and reverse-verification

Run from the repo root, at f1eb6dafe.

vitest run packages/layout/ packages/types/ packages/app-shell/... packages/core/src/actions/407 files, 4299 passed, 1 skipped, exit 0. type-check on all three packages green; eslint on the diff: 0 errors. check:control-bytes, check:self-import, check:esm-specifiers, check:phantom-deps, check:spec-symbols, check-changeset-presence, check-changeset-no-major: all green.

No build artifact sits between the edit and any vitest legvitest.config.mts aliases @object-ui/layout / types / app-shell to src, so an ablation cannot go falsely green through a stale dist. The type-check leg does read dist/*.d.ts; the dependency closure was built first and the artifact was confirmed to carry the new export (packages/layout/dist/NavigationRenderer.d.ts declares NAV_RUN_ACTION_PARAM).

Two legs, predicted before running:

LegPredictedObserved
Revert the withRunAction application in resolveHref (constant kept, so failures are assertion-level)5 red in resolveHref.runAction.test.ts — the four positive encodings plus the unresolved-template fallback; absence cases stay green; other two files greenexactly those 5 red, 28 green
Restore EnvironmentListToolbar + CloudOnboardingNext to origin/main4 red in the retirement pin (2 of 4 it.each file cases + 2 named cases); ObjectView/hook cases and all "one definition" cases green; existing deep-link suites greenexactly those 4 red, 134 green

Leg 2's second half is the point of the retirement pin: the existing deep-link suites pass happily against the restored private convention, so they cannot be what retires it.

Cross-repo follow-up (not in this PR)

Step 4 of the card — flipping the framework liveness-ledger row apps.navigation.children.runAction from planned/authorWarn to live — lands in objectstack. The evidence pointer that row needs is packages/layout/src/NavigationRenderer.tsx (resolveHref / NAV_RUN_ACTION_PARAM) for the producer and packages/app-shell/src/hooks/useNavRunAction.ts for the consumer.


Generated by Claude Code

…#5216)
`ObjectNavItemSchema.runAction` (objectstack#7253) declares which action an
object nav entry auto-runs on arrival at its list surface. objectui now reads
that declaration and retires the private `?runAction=` string convention it
replaces.
The convention had two hand-written definitions and no declaration:
`CloudOnboardingNext` built the query by string concatenation, and
`EnvironmentListToolbar` matched it by reading a bare 'runAction' literal off
window.location.search and comparing it to a hard-coded action name. Nothing
declared it, `objectui validate` stripped it, and RESERVED_URL_PARAMS did not
list the name, so a page could have repurposed it uncaught.
- types: NavigationItem declares `runAction`, derived from the spec's
object-nav variant; objectui's own nav zod schema declares it too, so
`objectui validate` stops silently discarding the deep link.
- layout: NAV_RUN_ACTION_PARAM is the param name's one definition, sited with
resolveHref (its only writer). resolveHref encodes the slot onto the LIST
landings only -- a recordId entry resolves to a record page, which has no
list toolbar to answer it.
- app-shell: useNavRunAction is the one implementation of read-once /
consume-once. The deep link is now honoured on every object list, not just
the environments list, and arms only on an action actually present at
list_toolbar (#4123: arming spends a one-shot intent). urlParams registers
the name by re-export rather than restating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-DVS4uIk_.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.83KB3.70KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.33KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.53KB113.13KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.97KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.92KB32.80KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 19, 2026 20:33
@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, round 17.

  • Path surface: 12 files — packages/types (app.ts, app.zod.ts), packages/layout (NavigationRenderer.tsx + test), packages/app-shell (urlParams.ts, hooks/useNavRunAction.ts + test, a retirement pin, environment/EnvironmentListToolbar.tsx, views/ObjectView.tsx, console/home/CloudOnboardingNext.tsx), one changeset. Zero governed-surface hits.
  • Gates: every gate job completed: success — Lint, Type Check, Test shards 1–4, Build & E2E, Build Docs, Doc Snippet / Doc Component Type Check, Changeset Declaration / Bump Policy / Fixed Group, Bundle Analysis, Control Byte Scan, Internal Docs Link Check, Skill Guide Path Check. Plus check:self-import, check:esm-specifiers, check:phantom-deps, check:spec-symbols green locally. No cancelled, no in_progress.

Zone 0 passed as a reading, not an inheritance

ObjectNavItemSchema from @objectstack/spec/ui in the installed 17.0.0 carries runAction: truecounter-probed through the same import: recordId true, objectName true, zzNotAKeyfalse. So the true discriminates rather than being a wrong-path artefact. That gate was the only thing making this card dispatchable and it was answered properly.

Fork condition: none, and measured rather than assumed. What the private convention could express that the declared slot cannot is nothing on the nav path — it was produced in exactly one file and consumed in exactly one, comparing a bare literal against a hard-coded create_environment. A single-value private protocol between two files, strictly less expressive than the slot. No compatibility shim, and none is warranted: no producer outside this repo emits the string, so producer and consumer both moved here.

The declaration change is the right kind, and it is worth naming why

Adding runAction to objectui's own nav Zod schema reads at first glance like widening. It is the opposite: that schema strips unknown keys, so before this PR an entry carrying a deep link validated clean through objectui validatewith the deep link silently discarded — the objectstack#4115 class. The key was already declared upstream; objectui was quietly dropping it. Clause-② honoured: no spec change, nothing widened.

Two consequences that improve on the card: the deep link is now honoured on every object list, not just sys_environment; and NAV_RUN_ACTION_PARAM is the param name's one definition, re-exported through urlParams so the reserved-param collision check is complete without a second spelling.

Two measurements that contradict the card, both reported rather than smoothed over

  1. The pinned 17.0.0 accepts runAction together with recordId — the parse-level exclusivity the card describes is not in this pin. The dev checked the local objectstack checkout and found the superRefinedoes exist on main (packages/spec/src/ui/app.zod.ts, "runAction cannot be combined with recordId"), correctly concluding this is the merged-but-unpublished state of plugin-gantt: schema 上的 viewMode 只被 ResourceWorkload 分支读,时间轴静默忽略 —— 且该键在 ObjectGanttSchema 与 spec 的 GanttConfigSchema 都未声明 #5074 / ListView never forwards the view-level map config block to plugin-map — spec's ListMapConfigSchema (objectstack#9340) is authorable but inert at runtime; switcher wiring included #5042 rather than a defect — and filing nothing. Recorded on The locked @objectstack/spec@17.0.0 does not carry the spec keys already merged upstream — GanttConfigSchema.viewMode (#5074) is the first card measurably stalled on it #5328 as its third instance, and a new shape: a missing refinement fails silent, where a missing key announces itself. It also made the PR's list-surface-only precedence load-bearing rather than defensive, and the dev's independent verdict matches the spec's own.
  2. runAction: '' parses as accepted, hence the empty-string-is-absent handling.

Verification

Two ablation legs, predicted before running, both matching by name and count: Leg A (revert the withRunAction application, keep the constant exported so failures land as assertions rather than a blunt import error — a good choice) → predicted 5 red, observed Tests 5 failed | 28 passed (33), the five named ones. Leg B (restore both consumers to origin/main) → predicted 4 red, observed Tests 4 failed | 134 passed (138).

Leg B's second half is the load-bearing part, and it is the same shape #5068 and #5120 measured this round: the pre-existing deep-link suites pass happily against the restored private convention. They cannot be what retires it — which is exactly why the source-scan pin exists.

Artifact position answered per leg: no dist between edit and any vitest leg (aliases verified at vitest.config.mts:245-282); the tsc leg does read dist/*.d.ts, so the dependency closure was built before judging anything and the rebuilt artifact was confirmed to carry the new export (packages/layout/dist/NavigationRenderer.d.ts:218) — so the green type-check is not cache.

The cross-repo follow-up is mine, and the dev improved its shape

The liveness-ledger row flip needs two evidence pointers, not the single NavigationRenderer one the card anticipated: NavigationRenderer.tsxwrites the deep link but runs nothing, while what makes the row genuinely live — a shell actually consuming the declared slot — is app-shell/src/hooks/useNavRunAction.ts, wired at views/ObjectView.tsx and gated at environment/EnvironmentListToolbar.tsx. A renderer-only pointer would overstate one half and understate the other. I will carry both into the objectstack follow-up with a Blocked-by: back-link once this merges.

Merging via the queue.


Generated by Claude Code

@os-support-ai
os-support-ai added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 88085e3Aug 19, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5216-nav-runaction-slot branch August 19, 2026 20:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consume the declared nav runAction slot — retire the private ?runAction= string convention

2 participants

@os-support-ai@claude