#10458·nocobase

Popup Markdown using ctx.popup.record resolves twice redundantly; with a formula field the resolves stall ~1s apart (~2s popup)

Author: alexzaechCreated Sep 4, 2026Updated Sep 6, 2026

* 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.record never resolves server-side — plain and formula alike, ~240 ms.
  • ctx.popup.record always 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 this

Timeline — 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 returned

Timeline — formula field via ctx.record (~230 ms)

  110ms  +32ms  GET  /api/flowModels:findOne
  154ms  +37ms  GET  /api/flowModels:findOne
  201ms  +29ms  GET  /api/<collection>:get

The 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):

json
{
  "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, updatedById

The 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:

json
{"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

  • #9860Infinite 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.
  • #7050Fix 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).
  • #8669inconsistent ctx.popup behaviour in runJS of a popup (closed). Correctness rather than performance, but another case of ctx.popup not 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

  1. Create a collection with plain string fields first_name, last_name, company, plus a formula field using the string engine with the expression {{first_name}} {{last_name}} ({{company}}) (full definition above).
  2. Add a Table block for it with an Edit or View popup action.
  3. Inside the popup, add a Markdown block referencing {{ctx.popup.record.<formula field>}}.
  4. Open DevTools → Network and filter to variables:resolve.
  5. 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

  1. A popup template should be resolved once per open, not twice.
  2. A reference to a field of the popup's own record should be served from the record already fetched by <collection>:get in the same open. Both plain and formula fields are present in that response, so no round trip should be needed — as ctx.record already demonstrates.
  3. A formula field reached via ctx.popup.record should not cost ~1.6 s more than the same field reached via ctx.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=144

Each 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.