fix: urlwidget subwidgets replace entire page instead of rendering in-place

Bug: index.ui has VBox with 3 urlwidget subwidgets (top.ui, center.ui, bottom.ui).
When RenderUrlWidget dispatched through ActionDispatcher, loadWidget called
onWidgetLoaded which replaced the ENTIRE root widget. The last urlwidget to
finish loading (bottom.ui) overwrote top.ui and center.ui.

Fix: RenderUrlWidget now maintains local state (loadedWidget) and renders
loaded content in-place. Added ActionDispatcher.loadWidgetInPlace() that loads
via callbacks without calling onWidgetLoaded. Each urlwidget replaces only
itself, preserving sibling widgets in the parent container.
This commit is contained in:
yumoqing 2026-05-21 14:27:34 +08:00
parent 2b3dd84a13
commit 5ba33b9e18
2 changed files with 133 additions and 10 deletions

View File

@ -83,6 +83,78 @@ class ActionDispatcher(
}
}
/**
* Load a widget and return it via callbacks does NOT call onWidgetLoaded.
* Used by RenderUrlWidget for in-place loading of subwidgets.
* Reuses the same HTTP client and handles 403/3xx redirects.
*/
suspend fun loadWidgetInPlace(
url: String,
onSuccess: (BricksWidget) -> Unit,
onError: (String) -> Unit
) {
val fullUrl = resolveUrl(url)
try {
val widget = http.fetchUi(fullUrl, authToken = context.authToken)
onSuccess(widget)
DevLogStore.log(
level = DevLogLevel.INFO,
message = "In-place load OK: $fullUrl",
source = DevLogSource.ACTION
)
} catch (e: BricksHttpException) {
handleHttpErrorInPlace(e, fullUrl, onSuccess, onError)
} catch (e: Exception) {
onError("Failed to load: ${e.message}")
DevLogStore.log(
level = DevLogLevel.ERROR,
message = "In-place load failed: $fullUrl",
details = e.message,
source = DevLogSource.ACTION,
stackTrace = e.stackTraceToString()
)
}
}
private suspend fun handleHttpErrorInPlace(
error: BricksHttpException,
requestUrl: String,
onSuccess: (BricksWidget) -> Unit,
onError: (String) -> Unit,
redirectDepth: Int = 0
) {
when (error.statusCode) {
403 -> {
try {
val loginWidget = http.fetchUi(resolveUrl(loginUiPath), authToken = context.authToken)
onDialog?.invoke(loginWidget, true)
} catch (loginError: Exception) {
onError("Forbidden: ${error.displayBody()}")
}
}
401 -> onError("Unauthorized: ${error.displayBody()}")
in 300..399 -> {
val location = error.location
if (location.isNullOrBlank()) {
onError("HTTP ${error.statusCode} without Location")
return
}
if (redirectDepth >= MAX_REDIRECTS) {
onError("Too many redirects: $location")
return
}
val nextUrl = resolveRedirectUrl(requestUrl, location)
DevLogStore.log(
level = DevLogLevel.INFO,
message = "Redirect ${error.statusCode}: $requestUrl -> $nextUrl",
source = DevLogSource.ACTION
)
loadWidgetInPlace(nextUrl, onSuccess, onError)
}
else -> onError("HTTP ${error.statusCode}: ${error.displayBody()}")
}
}
private fun handleUrlWidget(bind: BricksBind) {
val url = bind.options["url"]?.let {
if (it is JsonPrimitive) it.contentOrNull else null

View File

@ -517,8 +517,34 @@ private fun parseJsonObject(obj: kotlinx.serialization.json.JsonObject): BricksW
@Composable
private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
val url = WidgetOptions.getString(widget.options, "url", "")
// Loaded content kept in local state — renders in-place, NOT replacing the root widget.
// Using actionDispatcher.dispatch() would call onWidgetLoaded which replaces the entire
// page (context.setCurrentWidget). When multiple urlwidgets exist as siblings (e.g. index.ui
// with top.ui + center.ui + bottom.ui), the last one to finish loading would overwrite
// everything else. By maintaining local state each urlwidget replaces only itself.
var loadedWidget by remember { mutableStateOf<BricksWidget?>(null) }
var error by remember { mutableStateOf<String?>(null) }
if (loadedWidget != null) {
RenderWidget(loadedWidget!!, actionDispatcher)
return
}
if (error != null) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Failed to load: $url", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
error?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
return
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -527,16 +553,41 @@ private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispat
Text("Loading: $url", style = MaterialTheme.typography.bodySmall)
}
// 自动加载 url
// Load content in-place via ActionDispatcher's internal HTTP client.
// We use a callback approach so the ActionDispatcher can reuse its HTTP client
// and handle 403/3xx, but loadWidget must NOT call onWidgetLoaded for urlwidgets
// that are part of a subwidgets array.
LaunchedEffect(url) {
if (url.isNotEmpty() && actionDispatcher != null) {
val bind = BricksBind(
event = "auto",
actiontype = "urlwidget",
target = "self",
options = mapOf("url" to kotlinx.serialization.json.JsonPrimitive(url))
)
actionDispatcher.dispatch(bind)
if (url.isEmpty()) return@LaunchedEffect
try {
// Try to resolve the URL — if actionDispatcher is available, use its resolveUrl
// which handles relative paths via context.entireUrl(). Otherwise use the URL as-is.
val resolvedUrl = if (actionDispatcher != null) {
// Use reflection-free approach: dispatch a special bind and intercept via callback
// Since we can't access http directly, we need a different approach.
// For now, if url is already absolute (starts with http), use it directly.
if (url.startsWith("http://") || url.startsWith("https://")) url
else url // Relative URLs need the dispatcher's http client — see note below
} else {
url
}
// We need access to BricksHttp to fetch the widget.
// Since RenderUrlWidget only receives ActionDispatcher, we dispatch through it
// but with a flag to NOT replace the root widget.
// The cleanest fix: add an onWidgetLoadedInPlace callback to ActionDispatcher.
// For this fix, we handle it by dispatching and letting the dispatcher know
// this is an in-place load.
if (actionDispatcher != null) {
actionDispatcher.loadWidgetInPlace(
url = resolvedUrl,
onSuccess = { w -> loadedWidget = w },
onError = { msg -> error = msg }
)
}
} catch (e: Exception) {
error = e.message
}
}
}