ReviewOS

craft-native/craft

dialog.actionSheet falls back to the DOM renderer on a native screen, so the sheet is not a UIAlertController

#362
Closed glennmichael123 opened this 8 days ago ·
8 days ago

craft-native 0.0.204, iOS 26.5 simulator, an app with renderer: "native" whose screens are compiled .stx (no web view for the screen itself).

What happens

Calling craft.dialog.actionSheet({ title, actions }) from a native screen draws a centred rounded card with inset pill buttons, not a bottom-anchored UIAlertController. No Cancel is visible on it.

The two screenshots below are the same app: the first is an actionSheet with three actions (Choose photo, Remove photo, and a { style: 'cancel' }), the second a confirmAction({ destructive: true }), which routes to actionSheet as well.

Both render as a floating card in the middle of the screen. A real .actionSheet would be pinned to the bottom, full width, with Cancel in its own separated group.

Why I think it is the fallback

CraftNativeUI.swift is correct — CraftDialogs.actionSheet builds a UIAlertController(preferredStyle: .actionSheet) and explicitly guarantees a way out:

if !hasCancel {
    sheet.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in answer(nil) })
}

So what is on screen is not that code. The compiled native bundle contains the JS DOM renderer instead — native-dialog-panel, native-dialog-group, native-dialog-backdrop — reached through

dialog.actionSheet = ($) => bridge("dialog.actionSheet", [$], () => domSheet($), { fallbackOnError: true })

fallbackOnError: true means a failing bridge call is silent: the DOM renderer runs and the caller gets a plausible-looking dialog. On a native screen there is no page to style it, which is why it looks like a generic card.

What I would expect

  1. dialog.actionSheet / dialog.confirm / dialog.alert reach the Swift implementation on a native screen.
  2. If the bridge genuinely is unavailable there, the fallback should say so rather than silently rendering an unstyled substitute — fallbackOnError hides the real failure.

Why it matters beyond looks

The sheet is where destructive confirmations live. In our app "Remove your photo?" renders with its destructive action and no visible Cancel, so the only obvious way out of a destructive prompt is to perform it. The Swift path would not have that problem; the fallback is what produces it.

Happy to dig further or test a patch.

8 days ago

Update against craft-native 0.0.246 (was 0.0.204 when I filed this). Re-reading the current sources, the routing diagnosis in my original report does not hold any more, so I want to correct it rather than leave it standing.

Everything needed for a native screen to get a real UIAlertController is present on both sides:

  1. CraftNativeActions.swift routes the native-screen bridge to the system dialogs:
case ("Dialog", _):
    // The system's own alert, confirmation and action sheet, the same
    // ones a page gets (CraftNativeUI.swift), over the native screen.
    …
    case "actionSheet":
        CraftDialogs.actionSheet(body, from: nil) { … }
  1. capabilities(config:) advertises dialog unconditionally — it is in the base values array, not behind a config flag.

  2. The compiled native runtime installs the facade when that capability is present:

if (w.has("dialog")) p.dialog = {
  alert: h => k("Dialog", "alert", [h])…,
  confirm: h => k("Dialog", "confirm", [h])…,
  actionSheet: h => k("Dialog", "actionSheet", [h])…,
}
  1. craft is installed on globalThis, and the runtime sets n.window ??= n, so the window.craft.dialog.actionSheet lookup that @stacksjs/mobile performs does resolve.

So either this was fixed somewhere between 0.0.204 and 0.0.246, or the fallback was being taken for a reason other than the one I guessed. I have not re-run it on a device at 0.0.246, and I would rather say that than assert a second guess.

The part I would still raise, independent of the above

@stacksjs/mobile calls every dialog as

bridge("dialog.actionSheet", [options], () => domSheet(options), { fallbackOnError: true })

and that fallback is taken both when the method does not resolve and when the bridge throws. Neither case is reported. When it happens on a native screen the result is a DOM dialog drawn against stx's shimmed document — a centred card with inset pills, which is not what the platform draws and, in our app, showed a destructive confirmation whose only visible button was the destructive one.

A silent fallback to a visually different control is hard to notice and hard to diagnose; it took reading the compiled bundle to work out which renderer had run. Surfacing the bridge failure — a warning, or letting it reject when the host claims the capability — would turn that into something an app developer can see.

Feel free to close this if the routing was fixed in the interim; I will reopen with device evidence if I hit it again at 0.0.246.

8 days ago

Device evidence at craft-native 0.0.246, and the picture is narrower and better defined than my original report. Correcting it.

It is the sheet variant specifically, not dialogs generally

Three dialogs, one app, one session, all on native screens (renderer: "native", no web view):

callCancel rendered?
dialog.actionSheet — a profile's … menuno
dialog.actionSheet — a six-option "why are you reporting this?" pickerno
dialog.confirm — "Block this person?"yes
dialog.confirm — "Unblock?"yes

So confirm is fine. Only actionSheet loses it. Both sheets pass an explicit { id: 'cancel', title: …, style: 'cancel' }.

Tapping outside does dismiss it

This was the open question, and it decides severity. Tapping the scrim closes the sheet and selects nothing — verified against the database: no block row written, the report count unchanged.

So this is not a dead end. It is a sheet with no labelled way out whose only escape is an undiscoverable gesture, which is a different and smaller problem than the one I implied. Still worth fixing — a six-option report-reason picker where every visible control commits you, and backing out is a tap on empty space nobody told you about, is poor on its own and poor for accessibility in particular: a scrim tap is not an element VoiceOver can land on, so for that user there is genuinely no way out but to choose a reason.

Reproduced independently in a second app

A separate app on this machine (Wildloop, also native screens) hits exactly this. Its resources/functions/native-choice.ts sends an actions array ending in a style: 'cancel' entry, and its "Activity type" sheet draws five options and no Cancel. Two unrelated codebases, same shape.

That also rules out the possibility I raised in my earlier comment that this might be a web-view-only path. Both apps are on native screens.

What that leaves

The host Swift is still correct. CraftDialogs.actionSheet maps style: "cancel" to .cancel and synthesises one when none is supplied, so by that path a Cancel always exists. And what is drawn does not look like a UIAlertController of style .actionSheet at all — it is a centred card with inset pill rows, not a bottom-anchored group with a separated Cancel.

So on a native screen something other than CraftDialogs.actionSheet is drawing this, and that renderer is where the cancel action is dropped. The compiled native bundle does contain the JS DOM renderer (native-dialog-panel, native-dialog-group, native-dialog-backdrop), reached through bridge(..., { fallbackOnError: true }), which is still my best guess at what is running — and the scrim-dismiss behaviour fits it, since that renderer binds a backdrop click for the sheet variant.

What I cannot do from here is prove which renderer drew it. That needs someone who can instrument the bridge.

8 days ago

Two corrections to my previous comment, both from the author of the second app, and both narrowing it.

I overstated the accessibility impact for their case. Their eleven actionSheet call sites are preference pickers — feed scope, activity type, visibility, map layer, profile section, plan activity, territory game, trail section. For a VoiceOver user with no labelled exit, re-selecting the value that is already current gets them out, and in each of those the handler only sets local state, so it is a no-op. Awkward, not harmful. Their report-reason flow is not a sheet at all; it cycles in place.

So the case with teeth is the one in HQ.training: a six-option "why are you reporting this?" picker where every visible control commits a report. That should be the headline, not the general claim I made.

A better hypothesis for the cause, which I had not considered. What is drawn is a centred card with stacked pill rows. That is what preferredStyle: .alert produces, not .actionSheet, which iOS draws bottom-anchored with a separated Cancel.

If the native-screen path builds an alert where it means to build a sheet, the missing Cancel needs no filter to explain it: .alert has no implicit cancel affordance, and the code in CraftDialogs.actionSheet that synthesises one when none is supplied would simply not be on that path. That is a much simpler explanation than anything I proposed, and it predicts exactly what both apps see.

Worth checking how the native path presents it before anyone goes looking for code that strips cancel actions, because on this hypothesis there is no such code.

One more clue in the same direction: a scrim tap dismisses it, which means whatever is presenting it is modal with an outside-tap recogniser. That constrains which presentation path is live.

I cannot settle it from here — it needs someone who can instrument the bridge or read the native-screen dialog path directly.

8 days ago

The routing/fallback portion is now covered on main: native screens install the Dialog capability facade after bundle startup on both hosts (8976e149, cc4dad92, 7ccfacdd), and action-sheet bridge failures are surfaced with a warning instead of silently disappearing (4e056592). The iOS host path uses UIAlertController(preferredStyle: .actionSheet) and synthesizes a Cancel action when needed; generated-source tests cover the dispatcher and the native facade.

I am leaving the issue open for a fresh device reproduction against current main, because the original 0.0.246 observation may have predated those fixes.

8 days ago

Resolved on main by 8976e14, cc4dad9, and 4e05659. Native screens prefer the host dialog/actionSheet implementation and rebind it after bundle startup; source coverage is present. On main, unreleased.

Sign in to comment on this issue.