skip to content

Why can't a Cypress cy.origin() callback see variables declared outside it?

level: middleimportance: must knowfreq 62%

answer

  1. The callback leaves its scope behind
  2. Not a closure, a string
  3. Only args crosses the boundary
  4. Structured clone decides what fits
  5. Functions and DOM nodes never serialize

basics

~20 s

The callback is not a closure. Cypress stringifies it, ships it to a second instance of itself running in the other origin and evaluates it there, so the surrounding scope is gone. Data reaches it only through the args option.

solid answer

~50 s

Entering a `cy.origin()` block starts a second Cypress instance inside the secondary origin. The callback is **stringified**, sent across and evaluated there, so it has no link to the scope it was written in — referencing an outer `const` throws a `ReferenceError` at run time rather than at compile time. The only channel in is the `args` option: `cy.origin(url, { args }, (args) => {...})`. Cypress moves that object with the browser's structured clone algorithm, which carries plain objects, arrays, strings, numbers, `Date`, `Map` and `Set`, but refuses functions, symbols and DOM nodes, and drops the methods of a class instance. The same rule applies on the way out: whatever the block yields must be serializable, so yield a string from `.invoke('text')` rather than the element itself. Code, as opposed to data, comes in through `Cypress.require()`.

code

javascript · 15 lines
javascript
const account = { id: 'ACC-88213', month: '2026-08' }

it('reads the statement title from the third-party viewer', () => {
  cy.visit('https://statements.example.com/statements/2026-08')

  cy.origin(
    'https://viewer.pdfhost.example',
    { args: account },
    ({ id, month }) => {
      // `account` itself is out of scope here; only `args` arrived.
      cy.visit(`/documents/${id}/${month}`)
      cy.get('[data-cy=doc-title]').invoke('text')
    }
  ).should('contain', 'ACC-88213')
})

go deeper

for a junior

Recall that data reaches a cy.origin() callback only through the args option, and that the object you pass arrives as the callback's single argument.

for a middle

Explain that Cypress stringifies the callback and evaluates it in a second instance in the other origin, and that args crosses by the structured clone algorithm.

for a senior

Expect a failing spec whose callback leans on outer state and be able to restructure it: data through args, code through Cypress.require, assertions kept inside the block.

for a principal

Be ready to say how much of a suite should live inside cross-origin blocks at all, given that every value crossing the boundary must be plain serializable data.

## Two instances of Cypress, one test A `cy.origin()` block is not a scoping construct in your spec — it is a **remote procedure call**. When the block runs, Cypress injects a second copy of itself into the origin you named (the docs call this the *spec bridge*), sets up bidirectional communication with it, takes the callback function, converts it to a string, sends the string across, and evaluates it in that other instance. Everything follows from that one fact. The function that eventually runs was rebuilt from source text in a different JavaScript realm. It never had a closure over your spec, so: - a `const` or `let` declared above the block is simply not defined inside it, and you get a `ReferenceError` naming it - an `import` at the top of the spec file is not in scope either - helper functions defined in the spec are invisible, however small - values captured from a `beforeEach` hook or an outer `.then()` are equally gone This fails at **run time**, not at type-check time: the code looks perfectly valid to TypeScript and to your editor, which is why the mistake survives review so often. ## `args` is the only door in The `options` object accepts exactly one key, `args`, and its value is delivered as the callback's first and only argument: ```js const account = { id: 'ACC-88213', month: '2026-08' } cy.origin( 'https://viewer.pdfhost.example', { args: account }, ({ id, month }) => { cy.visit(`/documents/${id}/${month}`) } ) ``` Transport is the web platform's **structured clone algorithm** — the same mechanism used to post a message to a worker — so what can travel is decided by that algorithm, not by Cypress. | What you want to pass | Does it survive? | What to do instead | | --- | --- | --- | | Strings, numbers, booleans, `null` | Yes | — | | Plain objects and arrays, nested | Yes | — | | `Date`, `Map`, `Set`, typed arrays | Yes | — | | A function or an arrow function | No | move the logic into the callback body | | A DOM element or jQuery object from `cy.get()` | No | re-query it inside the block | | A class instance such as a page object | Structure survives, methods do not | import the class inside with `Cypress.require()` | | A `Symbol` | No | pass a string key instead | When something unsupported is passed, the failure is explicit: Cypress reports that the value could not be serialized and names the reason — a function, a symbol, or a property the algorithm does not support. ## The same wall on the way out `cy.origin()` yields whatever the last command inside the callback yielded, and that value has to come back across the same boundary. So this fails: ```js cy.origin('https://viewer.pdfhost.example', () => { cy.get('[data-cy=doc-title]') // yields a jQuery element… }).should('contain', 'ACC-88213') // …which cannot be serialized back ``` There are two clean fixes, and the first is usually better: 1. **Keep the assertion inside the block.** Nothing needs to cross at all, and the failure message stays attached to the origin it came from. 2. **Yield a serializable projection.** `cy.get('[data-cy=doc-title]').invoke('text')` yields a string, which crosses happily and can be asserted on outside. A thrown value obeys the rule too: if a command inside the block throws something the algorithm cannot clone, Cypress reports that it could not serialize the thrown value rather than the original error, which is a confusing failure to debug — so throw `Error` objects and plain values inside the callback. ## Getting code across, not just data Because the callback is extracted from the spec bundle before it is preprocessed, neither CommonJS `require()` nor a dynamic `import()` works inside it. Cypress supplies `Cypress.require()` for this, gated behind the `experimentalOriginDependencies` configuration option. It takes a **static, single-line string** — a variable holding a module name will not resolve — and it may only be called inside a `cy.origin()` callback. That is how a shared page object or an npm package reaches the block: require the module inside the callback and construct it there, and keep using `args` for the data it needs. The execution context also persists between blocks for the **same** origin within a spec, so requiring your custom commands once in a `before` hook makes them available to every later block for that origin.

  • How do you use a shared page object or an npm package inside a Cypress cy.origin() callback?
    Import it inside the callback with `Cypress.require()` and construct it there, which needs the `experimentalOriginDependencies` option enabled. The argument must be a static single-line string, and the call only works inside a `cy.origin()` callback. Passing a class instance through `args` does not work: the structured clone keeps its data and drops its methods.
  • What happens when the last command in a Cypress cy.origin() block yields a DOM element?
    The block yields it, but the value cannot cross the origin boundary, so any attempt to use it outside throws a serialization error. Either finish the assertion inside the block, or yield something serializable first — `.invoke('text')` for a string, `.its('length')` for a count.

Think of args as the customs declaration on a parcel: whatever you did not write on the form stays behind at the border, and some things — a live function, a DOM node — cannot be boxed for the journey at all.

saying these in an interview costs you the question

  • Assumes the callback closes over the spec's variables
  • Passes a page-object instance and expects its methods to survive
  • Uses import or require directly inside the callback
  • Chains an assertion onto an element yielded out of the block
  • Treats args as a readability convenience rather than the only channel