Popup Markdown using ctx.popup.record resolves twice redundantly; with a formula field the resolves stall ~1s apart (~2s popup)
* Describe the bug
A Markdown block inside a popup that references {{ctx.popup.record.<field>}} issues POST /api/variables:resolve twice with byte-identical payloads, even when the referenced field is already present in the record the same popup just fetched.
When the referenced field is a formula field, the two resolves additionally move ~1000 ms apart and the popup takes ~2 s instead of ~250 ms. The formula in question is trivial — a string-engine template, {{first_name}} {{last_name}} ({{company}}), over three plain columns of the same record, whose computed value the API has already returned in this very popup open.
Rewriting the same block against {{ctx.record.<field>}} emits zero variables:resolve requests and renders identical output in ~240 ms — including for the formula field. So formula fields are not inherently slow; the cost appears only on the ctx.popup.record path.
Throughout, the main thread is idle: a PerformanceObserver on longtask records 0 ms of blocking in every configuration. The ~1.6 s is spent waiting, not computing.
Measurements
Full matrix. Same collection, same records, same browser session; only the Markdown block's expression changes. Blocking was 0 ms in every row.
| Context path | Field type | Wall | XHRs | variables:resolve |
Idle gaps |
|---|---|---|---|---|---|
| (no block) | — | 260 ms | 3 | 0 | none |
ctx.record |
plain | 239 ms | 3 | 0 | none |
ctx.record |
formula | 230 / 249 ms | 3 | 0 | none |
ctx.popup.record |
plain | 407 / 416 / 417 ms | 5 | 2 | 63–74 ms |
ctx.popup.record |
formula | 1999 ms | 5 | 2 | 656 ms + 958 ms |
Rows were measured across multiple records; repeated cells show the individual runs.
Reading the matrix:
ctx.recordnever resolves server-side — plain and formula alike, ~240 ms.ctx.popup.recordalways emits two redundant resolves — ~175 ms even for a plain field already in hand.ctx.popup.record+ formula field is the pathological case — the same two resolves, but now ~1000 ms apart, for a total of ~2 s.
The last effect is an interaction. Neither the context path alone nor the formula field alone produces it.
Timeline — formula field via ctx.popup.record (~2 s)
158ms +35ms GET /api/flowModels:findOne
207ms +37ms GET /api/flowModels:findOne
256ms +43ms GET /api/<collection>:get <- response already contains the field
955ms +43ms POST /api/variables:resolve <- 656ms idle before this
1956ms +43ms POST /api/variables:resolve <- 958ms idle before thisTimeline — plain field via ctx.popup.record (~410 ms)
Same two redundant resolves, without the long stalls:
105ms +35ms GET /api/flowModels:findOne
153ms +47ms GET /api/flowModels:findOne
209ms +38ms GET /api/<collection>:get
318ms +52ms POST /api/variables:resolve <- 71ms idle before this
384ms +32ms POST /api/variables:resolve <- 14ms after the previous returnedTimeline — formula field via ctx.record (~230 ms)
110ms +32ms GET /api/flowModels:findOne
154ms +37ms GET /api/flowModels:findOne
201ms +29ms GET /api/<collection>:getThe formula field under test
Nothing exotic — a string-engine template that concatenates three plain columns of the same record. No math engine, no relations, no async lookups, and it is display-only (x-read-pretty):
{
"engine": "string",
"dataType": "string",
"expression": "{{first_name}} {{last_name}} ({{company}})",
"uiSchema": {
"type": "string",
"title": "Full Name and Company",
"x-component": "Formula.Result",
"x-read-pretty": true,
"x-component-props": { "stringMode": true }
}
}All three referenced columns are plain string fields on the same collection, and all three are returned by <collection>:get in the same popup open — as is the computed result itself. There is no work here that requires a server round trip, let alone two spaced a second apart.
The record fetch already contains both fields
Captured from the app's own authenticated <collection>:get response (field names only):
arrival_time, comment, company, createdAt, createdBy, createdById,
departure_time, first_name, full_name_and_company, id, internal_contact,
last_name, meeting_room, reason_for_visit, updatedAt, updatedByIdThe plain field (first_name) and the formula field (full_name_and_company) are both present at the ~205 ms mark. Every variables:resolve call in these traces is redundant.
The two request bodies are identical
Captured by patching XMLHttpRequest.prototype.send. Identifiers redacted; the bodies differ only in the generated id:
{"values":{"batch":[{"id":"1788511102322-sf2vsovzwk","rd":"<redacted>",
"template":{"popup":{"record":{"<field>":"{{ctx.popup.record.<field>}}"}}},
"contextParams":{"popup.record":{"collection":"<collection>",
"filterByTk":"<id>","dataSourceKey":"main"}}}]}}
{"values":{"batch":[{"id":"1788511103330-wr7n7fl3qen","rd":"<redacted>",
"template":{"popup":{"record":{"<field>":"{{ctx.popup.record.<field>}}"}}},
"contextParams":{"popup.record":{"collection":"<collection>",
"filterByTk":"<id>","dataSourceKey":"main"}}}]}}The id prefixes are timestamps — 1788511102322 and 1788511103330, exactly 1008 ms apart.
contextParams sends a reference to the record (collection + filterByTk) rather than using the copy the client already holds, which appears to be why this path round-trips at all.
Where the delay is scheduled
Patching setTimeout to log delays ≥ 80 ms during the popup open shows the resolve is reached through a debounced reactive re-run rather than direct request latency:
66ms delay=500ms uE (vendor-lodash) | uq.b | uq.<anonymous> | uq.dispatchEvent
193ms delay=100ms uE (vendor-lodash) | ud.b [as _rerunLastAutoRun] | onModelLoaded | refreshDeps
247ms delay=100ms uE (vendor-lodash) | d4.b [as _rerunLastAutoRun] | ...uE is lodash debounce. The chain appears to re-enter _rerunLastAutoRun and re-resolve the same template rather than reusing the first result.
Related issues
- #9860 — Infinite loop of variables:resolve requests when popup edit is opened with field assignment linkage rules (closed). Related but distinct: that report is an unbounded loop driven by linkage rules assigning field values, where each assignment re-triggers rule evaluation. This report has no linkage rules, involves a read-only Markdown block, and produces exactly two resolves — a bounded duplicate rather than a feedback loop. The common ground is that both go through the flow engine's debounced reactive re-run. #9860 was closed after a maintainer could not reproduce it, with no linked PR or commit.
- #7050 — Fix the issue where linkage rules cause infinite loop (referenced by #9860 as a prior fix for the same class of problem in the older engine).
- #8669 — inconsistent ctx.popup behaviour in runJS of a popup (closed). Correctness rather than performance, but another case of
ctx.popupnot resolving as expected.
Unlike #9860, the reproduction here is deterministic and minimal: no linkage rules, no relations, a read-only block, and four controls that isolate the two contributing factors independently.
* Environment
- NocoBase version: 2.3.0-beta.7 (
@nocobase/server) - Database type and version: PostgreSQL 17
- OS: Linux (Docker host); client is macOS 26.7 with Chrome 152
- Deployment Methods: Docker
- Docker image version: nocobase/nocobase:beta-full
- NodeJS version: v22.23.2
* How To Reproduce
- Create a collection with plain
stringfieldsfirst_name,last_name,company, plus aformulafield using the string engine with the expression{{first_name}} {{last_name}} ({{company}})(full definition above). - Add a Table block for it with an Edit or View popup action.
- Inside the popup, add a Markdown block referencing
{{ctx.popup.record.<formula field>}}. - Open DevTools → Network and filter to
variables:resolve. - Click the action on any row.
Observed: two variables:resolve requests with identical payloads, ~1 s apart. The popup settles only after the second returns. Total ~2 s.
Control A — same context path, plain field ({{ctx.popup.record.first_name}}): still two redundant resolves, popup settles in ~410 ms.
Control B — same formula field, local context ({{ctx.record.<formula field>}}): no variables:resolve at all, popup opens in ~230 ms.
Control C — plain field, local context ({{ctx.record.first_name}}): no resolves, ~240 ms.
Control D — delete the Markdown block: ~260 ms.
Expected behavior
- A popup template should be resolved once per open, not twice.
- A reference to a field of the popup's own record should be served from the record already fetched by
<collection>:getin the same open. Both plain and formula fields are present in that response, so no round trip should be needed — asctx.recordalready demonstrates. - A
formulafield reached viactx.popup.recordshould not cost ~1.6 s more than the same field reached viactx.record, and template resolution should not sit behind debounce timers that idle the main thread while the popup's data is already available.
Screenshots
Not applicable — the symptom is timing, not appearance. Waterfalls and idle gaps are given above.
Logs
Server-side both requests are answered promptly, which is why this reads as client-side scheduling rather than an API problem:
POST /api/variables:resolve 200 cost=34ms bodySize=145
POST /api/variables:resolve 200 cost=26ms bodySize=144Each is also accompanied by:
{"level":"warn","message":"[Workflow pre-action]: collection \"variables\" not found",
"module":"variables","submodule":"resolve"}That warning may be unrelated, but it is emitted on every variables:resolve call.
Measurements collected with browser instrumentation (patched XMLHttpRequest, PerformanceObserver); analysis and write-up assisted by an AI coding tool. All figures are from a live instance and are reproducible via the controls above.
Source: nocobase/nocobase