Skip to content

feat(core,app-shell): read ActionSchema.onSuccess for post-success navigation - #5933

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5221-onsuccess-navigation
Aug 24, 2026
Merged

feat(core,app-shell): read ActionSchema.onSuccess for post-success navigation#5933
os-zhuang merged 1 commit into
mainfrom
claude/issue-5221-onsuccess-navigation

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#5221

Console half of ActionSchema.onSuccess post-success navigation — the SPA route hop, the
api/script execution path, and the ${result.*} interpolation scope.

Pin measurement chain

The card asserts both blockers satisfied and the spec half merged. That is a claim about
another repo's main, so it was measured here, on this branch's base, before any code was
written.

⚠️ A first reading was wrong and is corrected here. The pin was first read from the
shared checkout /home/user/objectui, whose HEAD was an older commit; the claim comment on
the issue quotes that reading and declares a divergence window. Re-measured against this
branch's base (0fce2ef81), there is no divergence window — the resolved pin is the
registry's latest.

1 — exactly one resolved version, nothing below it. From pnpm-lock.yaml at the branch base:

$ grep -nE "^ '@objectstack/spec@" pnpm-lock.yaml
4198: '@objectstack/spec@17.2.0':
12507: '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))':
$ grep -A3 "^ '@objectstack/spec':" pnpm-lock.yaml | grep -E "specifier:|version:" | sort | uniq -c
31 specifier: ^17.0.0
31 version: 17.2.0(ai@7.0.65(zod@4.4.3))

All 31 importers resolve to one version; no entry sits below it.

2 — that version is published, and is latest.

$ npm view @objectstack/spec dist-tags.latest
17.2.0

So the pin does not lag: nothing to declare.

3 — the installed package's own declaration carries the ruled shape. From
node_modules/@objectstack/spec/dist/action.zod-*.d.ts:

onSuccess: z.ZodOptional<z.ZodObject<{navigate: z.ZodString;openIn: z.ZodDefault<z.ZodEnum<{self: "self";newTab: "newTab";}>>;},z.core.$strict>>;

Closed strict object · navigate: string · openIn a ZodDefault over the closed enum.
The refine is in the same artifact:

if(data.onSuccess&&data.type!=="api"&&data.type!=="script"){

4 — a runtime parse against the installed pin, because a declaration is not behaviour:

PIN-1 default materialised: true {"navigate":"/app/crm/contacts/${result.id}","openIn":"self"}
PIN-2 newTab accepted: true {"navigate":"/x","openIn":"newTab"}
PIN-3 kebab refused: true onSuccess.openIn: `onSuccess.openIn` spells the new-tab member `'newTab'` …
PIN-4 non api/script refused: true onSuccess
PIN-5 strict closed: true unrecognized_keys
PIN-6 script accepted: true {"navigate":"/app/z/${result.id}","openIn":"self"}

PIN-1 is the load-bearing one: parse output always carries openIn resolved, which is why
no default is written on the console side.

What was actually broken

Not "the key is unread". ActionRunner has its own, older ActionDef.onSuccess meaning —
ActionDef | ActionDef[], chained callbacks — and the ruled block fell into it: it was
dispatched as an action, executeNavigation read navigate.to off a string, and the author
got a red toast reading "No URL provided for navigation action" and no hop. That is the
invisible-failure class, with an error message pointing away from the cause.

The two are told apart by the spec's own declaration — a non-array object whose navigate
is a string. Nothing else can produce that shape: the spec object is strict with
navigate: z.string() required, and on a callback ActionDefnavigate is the deprecated
nested navigation envelope that executeNavigation reads to/target/redirect off, so
a bare string there has never been runnable. This is a narrowing to the declared
contract
, not a lenient fallback: a shape the spec refuses gains no new reading.

What changed

  • ActionRunner.handlePostExecution performs the declared hop through
    navigationHandler — the SPA seam every other navigator in that file already uses, which
    the console wires to react-router's navigate. No navigation mechanism is introduced.
    This one seam covers both types the refine admits: api (the console's apiHandler),
    script (the console server-action wrapper), and the runner's own executeAPI fallback.
  • ActionRunner.interpolateTarget gains a ${result.*} scope beside ${param.*} and
    ${ctx.*}. The scope map defines the vocabulary and the pattern is built from its
    keys, so result exists only where a result exists — a target interpolated before its
    request still has no result to name, rather than silently blanking the token.
    ${result.*} resolves against the handler's own return value via readActionPayload,
    one level below the action envelope — the level the redirectUrl convention already reads.
  • openIn: read as the one member that changes the branch. No ?? 'self' — that would
    be a second source of truth for a default the producer already resolved. The two spellings
    are kept apart: this reads onSuccess.openIn ('self' | 'newTab') and never the top-level
    type: 'url' switch ('self' | 'new-tab').
  • consoleServerAction.ts gains the handler-return half: a handler may return
    openIn: 'self' beside its redirectUrl to ask for the same-tab jump, while a
    redirectUrlwithoutopenIn keeps its shipped new-tab behaviour — no silent flip.
    When the action declares an onSuccess block, the wrapper defers to the runner and only
    tidies its pre-opened tab, so one navigation happens rather than two.

Reachability — please read before merging

The runner half is live, but not from every surface. Measured on this branch:

surfaceforwards the def howonSuccess reaches the runner
DeclaredActionsBar (record header)full-def spreadyes
ObjectGrid.onActionDef (row actions)full-def spreadyes
RelatedRecordActionsBridgefull-def spreadyes
useNavActionDispatchfull-def spreadyes
action:button / action:icon / action:group / action:menuexplicit whitelistno
$ for f in action-button action-icon action-group action-menu; do
grep -c "onSuccess" packages/components/src/renderers/action/$f.tsx; done
0
0
0
0

Those four are the subject of #5493, which is where their wiring belongs — deliberately not
ridden in here. The customer report this card cites (clone-then-jump from a record header)
lands on the record-header surface, which is live with this change.

⚠️#5493's body and the matching KNOWN_GAPS reason text in
scripts/check-action-forward-parity.mjs both state that the runner "has honoured it all
along", citing the chained-callback lines. That premise was false for the ruled shape — the
callback path failed on it, as above — and this PR is what makes it true. A comment
recording that measurement has been left on that card so the next reader does not act on the
stale wording. check:action-forward-parity still exits 0 here; the ratchet is undisturbed.

Verification

All legs below ran at 59f63aab9, after the final commit, on a tree git status reports
clean.

Cross-package resolution — no stale dist/ in the vitest legs. The root vitest config
aliases the package to source, so these tests never read build output:

vitest.config.mts:257: '@object-ui/core': path.resolve(__dirname, './packages/core/src'),

For the type-check leg, which does resolve through package exports, the new export was
confirmed to have reached dist:

packages/core/dist/actions/ActionRunner.d.ts:861:export declare function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null;

The arms, and what each is discriminated against. Reverse verification ablated one
mechanism at a time; each mutation was confirmed on disk by grepping the injected and the
removed text (never an editor exit code), each direction was predicted before running, and
each leg was restored with git diff HEAD --stat empty. Every script carried
trap '<restore>' EXIT INT TERM.

ablationpredictedobserved
A — withhold the ${result.*} scopered, broad: the token stays literal in the route8 failed / 5 passed
B — ignore openIn (force same tab)red on exactly the newTab arm; every 'self' arm green1 failed / 12 passed
C — drop the wrapper's onSuccess deferralred on the double-navigation arm only1 failed / 20 passed
D — let the two openIn spellings crossred on exactly the two "spellings stay apart" arms2 failed / 11 passed
E — drop the URL scheme guardred on exactly the javascript: arm1 failed / 12 passed
F — discriminator always claims the blockred on the legacy-callback arm in both suites2 failed / 32 passed
G — hop even when the action failedred on exactly the failed-action arm1 failed / 12 passed
H — drop the handler-returned openIn: 'self' branchred on the three same-tab arms; the new-tab arms stay green as its positive controls3 failed / 18 passed
I — accept the kebab spelling as same-tabred on exactly the wrapper's kebab arm1 failed / 20 passed

Every assertion in both new suites therefore has a demonstrated failure mode; there are no
legs left that pass regardless of the subject. The two "X did not happen" arms that worried
me most — javascript: refused, and no-navigation-on-failure — are E and G. The
"no onSuccess navigates nowhere" arm carries its positive control inside the same test
(same runner, same harness, one key added), because that assertion passes just as well
against a dead harness.

⚠️ One ablation was a no-op on its first attempt and is reported as such. E's first
anchor, the bare if (!this.isValidUrl(url)) {, matches twice in ActionRunner.ts, so
the write was refused and the suite read green — a green that meant "nothing was
ablated", not "the guard is load-bearing". The on-disk confirmation caught it
(injected-text count = 0); E was re-run against a unique anchor and is the row above.

Commands, with exit codes captured before any pipe.

pnpm exec vitest run --maxWorkers=2 packages/core/ packages/app-shell/
→ Test Files 609 passed (609) · Tests 6965 passed | 1 skipped (6966)
→ os-verify-lock: VERDICT command-exit 0 · held the lock 976s
pnpm --workspace-concurrency=2 --filter "@object-ui/core" --filter "@object-ui/app-shell" type-check
→ packages/core type-check$ tsc --noEmit && tsc -p tsconfig.test.json → Done
→ packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json → Done
→ os-verify-lock: VERDICT command-exit 0

Both script names are echoed above, so neither run was a zero-match silent pass.

Gates — each quoted by its own verdict line, not by a shell status.

check-changeset-presence EXIT=0 ✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major EXIT=0 ✅ No changeset declares a `major` bump.
check-changeset-fixed EXIT=0 ✅ All workspace packages are in the changeset fixed group.
check-control-bytes EXIT=0 ✅ check-control-bytes: OK (scanned 4923 tracked text file(s); skipped 85 binary).
check-phantom-dependencies EXIT=0 ✅ Every in-scope import is declared by the package that publishes it.
check:action-forward-parity EXIT=0 (the ledger for this very key — undisturbed)
check:spec-symbols EXIT=0 ✅ spec symbol derivation: 1300 files scanned against 4959 spec export names
check:self-import EXIT=0 ✅ No package names itself inside its own src/.
check:esm-specifiers EXIT=0 no un-ledgered package emits an extensionless relative specifier
check:published-dist EXIT=0 ✅ No published package's build output carries tooling material.
check:eager-closure EXIT=0 ✅ Console eager closure is 3231.0 KB gzipped across 52 of 508 chunks (budget: 3990.2 KB, headroom: 759.2 KB)
check-lint-coverage EXIT=0 ✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).

check:eager-closure refuses to report without a console build (it calls an absent report a
broken gauge, not a passing budget), so apps/console was built first and the line above is
a real verdict rather than a skipped one.

The i18n gates were run even though this change adds no user-facing string — the one string
added is a developer console.warn:

check-i18n-call-site-keys EXIT=0 every in-scope call-site key resolves against the en pack (2929 keys)
check-i18n-en-drift EXIT=0 No en value changed in this range.

Lint — no narrowing to declare; the repo-wide scan was run.eslint . --no-inline-config
over the population eslint's own config selects:

population (files eslint's own config selected): 3610
errorCount total: 89 warningCount total: 10865

Every file this PR touches carries 0 errors. The 89 are pre-existing and sit outside the
46 linted packages — check-lint-coverage reports 0 with outstanding errors (0 total)
across those. The added test code follows the surrounding files' existing as any fixture
convention, which lints as a warning; .github/workflows/lint.yml states --max-warnings is
deliberately unset, so warnings are not a gate.

Not in scope

  • plugin-form: navigateOnSuccess is mount-blind and says nothing when its destination is refused — the key has no ruling and its own contract question is still open #5034 is plugin-form's navigateOnSuccess — a different key with different call sites,
    and untouched here. The two shapes are deliberately kept apart.
  • Retiring ActionRunner's legacy ActionDef.onSuccess chained-callback channel. Measured
    while working: it is unreachable from validated metadata (the spec strict-refuses
    { type: … } inside onSuccess) and has zero producers in this repo outside
    ActionRunner's own two unit tests. Removing an exported runtime contract is its own
    card; filed separately and left running here.
  • The precedence between a declared onSuccess block and a handler-returned redirectUrl
    when an action carries both. The spec rules each surface's own default but not this.
    Shipping something coherent required picking one, so the declared block wins and the choice
    is marked at the line that implements it. Escalated in the dev report for a ruling.

Generated by Claude Code

…vigation
`@objectstack/spec` declares `onSuccess` as a closed strict object
`{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
`type: 'api'` and `type: 'script'`. Nothing in this renderer read it, so the
declared hop never happened: the block fell into `ActionRunner`'s older
`ActionDef.onSuccess` chained-callback channel, was dispatched as an action,
and failed inside `executeNavigation` with "No URL provided for navigation
action" — a red toast and no jump.
`handlePostExecution` now performs the hop through `navigationHandler`, the
SPA seam the console wires to react-router's `navigate`. `interpolateTarget`
gains a `${result.*}` scope, resolved against the handler's own return value
via `readActionPayload` and supplied only by this call site. No `openIn`
default is written here — spec materialises `.default('self')` — and the two
`openIn` spellings are kept apart.
The console server-action wrapper gains the handler-return half: an explicit
`openIn: 'self'` alongside `redirectUrl` takes the same-tab route hop, while a
`redirectUrl` without it keeps its shipped new-tab behaviour. A declared
`onSuccess` block defers to the runner so one navigation happens, not two.
Part of #5221
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3231.1 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-BBHJIuAX.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.13KB3.77KB
app-shell (runtime-config.js)13.57KB4.78KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.18KB114.10KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.32KB34.42KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)243.38KB61.72KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
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.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

Console half of ActionSchema.onSuccess navigation (objectstack#9566/#9474): SPA route hop, executeAPI handling, ${result.*} interpolation scope

2 participants

@os-zhuang@claude