qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete()

setBaseUrl() and setSource() used to fire refresh() inline as soon as
both `baseUrl` and `source` were populated — but setToken() never
triggered a refresh. QML evaluates literal property assignments before
bindings to other objects' properties, so a model declared with
literal `source` plus bindings to `BackendConnection.url` /
`BackendConnection.token` (the exact shape of make:bridge:window's
output) could fire its GET *before* the `token` binding had landed.
The unauthenticated request hit Symfony's SessionAuthenticator, came
back 401, and the model parked at `ready === false` with an empty
list. Mercure subscribed anonymously (the model explicitly clears the
SSE client's bearer), so subsequent server-side mutations propagated
fine — masking the initial-fetch failure as "list is empty until
something changes". Hit by the second window in examples/todo.

Both classes now implement QQmlParserStatus and trigger the initial
refresh from componentComplete(), where every binding (literal *and*
singleton-derived) is guaranteed to have landed. After completion,
individual setter changes still trigger refresh inline — so token
rotation / URL reassignment after first load behave unchanged.

Regression test under framework/qml/tests/tst_reactive_list_model.qml
using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper
that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the
regression is observable; verified the test fails against the unfixed
production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on
the captured Authorization header).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:19:56 +02:00
parent a43b440b20
commit c673ec22e2
10 changed files with 395 additions and 12 deletions

View File

@@ -29,13 +29,21 @@ ReactiveObject::~ReactiveObject()
qDeleteAll(m_echoTimers);
}
void ReactiveObject::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveObject::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveObject::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveObject::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveObject::setTopic(const QString& v)