Skip to content

A type: 'modal' action's server-side handler is unreachable: objectui falls through to /actions, the framework 400s it — and app-todo ships two orphaned handlers as a result #3959

Description

@os-zhuang

Found while implementing ADR-0110 (#3958). The D5 reconciliation flagged real orphans in a shipped example on its very first run, and tracing why exposed a contradiction between the client and the framework about whether a modal action has a server half.

Pre-existing and not a regression from #3958 — these handlers were already unreachable through the Console. Filing rather than widening that PR's scope (Prime Directive #10).


✅ RESOLVED — option 2 (modal stays client-only)

Fixed by objectstack#3974 (c5c78bb) and objectui#2973 (a136322).

Why option 2. The spec's own wording settles it — a modal action's target is "the modal/page name to open", i.e. a thing to render, and SERVER_DISPATCHED_ACTION_TYPES deliberately excludes it. actions.mdx had already documented the correct alternative in its body-belongs-to-script callout: "To collect input and run logic, use type: 'script' with params — the same dialog is collected, then the body runs with those values." The client's fallthrough was the outlier, not the framework's 400.

What changed.

  • objectuimodalActionHandler no longer falls through to serverActionHandler. An unresolvable target is reported as the authoring mistake it is, naming the action, the dud target, and the way out. The superseded test (which asserted the fallthrough POSTed /actions) now asserts the refusal.
  • frameworkapp-todo's defer_task / set_reminder are type: 'script' with target pointing at their real handler keys. They already declared exactly the params their handlers read (new_due_date / reason, reminder_date), so they were always "collect input, then run server-side" — only the type was wrong. deferTask and setReminder now execute for the first time.
  • docs — the action-type table said modal meant "collect input, then submit to a handler", contradicting the same page's REST table (modal → 400, "nothing for the server to run"). That inconsistency is very likely why the example was written the way it was; corrected to point at script + params.

Reconciliation after the fix, using the same key derivation dispatch uses:

registered:            cloneTask, completeTask, deferTask, deleteCompletedTasks,
                       exportTasksToCSV, massCompleteTasks, setReminder, startTask
UNDECLARED handlers:   none
script declarations with NO handler: none

Clean in both directions — the example no longer trips the [action-governance] boot warning it was about to start emitting, so nothing scaffolded from it inherits the broken shape.

Both Dogfood Regression Gate shards passed on the framework side, which is the meaningful confirmation here: that gate exercises app-todo directly.


Original report

The contradiction

objectui said a modal action falls through to its server handler. packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:

/**
 * `type: 'modal'` dispatch. The action's `target` names the page to open
 * (spec: "the modal/page name to open"), so try to render it client-side
 * first. When it names neither a page nor an object there is nothing to
 * render — the modal was the param dialog the runner already collected — so
 * complete the action through its server-side handler, which is how a modal
 * action bound to `engine.registerAction(...)` (or an inline `body`) still
 * runs.
 */
const modalActionHandler = useCallback(async (action, context) => {
  const schema = action.modal ?? action.target ?? action.params?.schema;
  const descriptor = schema != null ? await resolveModalTarget(schema) : null;
  if (descriptor) return modalHandler(descriptor);
  return serverActionHandler(action, context);   // ← the documented fallthrough
}, [...]);

The framework said it has none. packages/runtime/src/action-execution.ts:

export function headlessActionTypeError(deps, action, objectName) {
    const type = action?.type ?? 'script';
    if (SERVER_DISPATCHED_ACTION_TYPES.has(type)) return null;
    ...
    return `Action '${name}'${on} is \`type: '${type}'\` — a client-side action with no server dispatch. `
         + `The renderer opens its \`target\`; there is nothing for the server to run.`;
}

modal is not in SERVER_DISPATCHED_ACTION_TYPES, so the fallthrough landed on a 400 — the one path objectui documented as "how a modal action bound to engine.registerAction(...) still runs" was the exact path the server refused.

What it already cost: two dead handlers in examples/app-todo

Handler key Declaration Its target
deferTask defer_task (type: 'modal') defer_task_modal
setReminder set_reminder (type: 'modal') set_reminder_modal

task.handlers.ts exported real bodies for both. Nothing could invoke them — through the Console the modal either rendered (server never called) or fell through to a 400; over REST /actions/todo_task/defer_task resolved the declaration → type: 'modal' → 400, and /actions/todo_task/deferTask found no declaration → 404 since ADR-0110 D3.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions