Why is State Final not "final"?

If you look closely, you’ll see that Typst is actually warning you about a problem with your document:

Layout did not converge within 5 attempts

This problem is hard to debug in general, but here the source of the problem is fairly easy to recognize and fix: your state updates are structured like

context {
  x = foo.get()
  ..
  foo.update(x)
}

instead of the proper form:

foo.update(x => {
  ..
  x
})

This uses the form of update() where you give it a function that computes the new value from the old one.

The docs have the following example that matches your use case:

#let compute(expr) = [
  #s.update(x =>
    eval(expr.replace("x", str(x)))
  )
  New value is #context s.get().
]

here there is first an update that uses a function, followed by a context get() that displays the new value.

Applied to your use case, the code would look like this:

#let change_state(ref, num) = {
  state("test_state").update(ts => {
    if ts.at(ref).at(2) != "CHANGED" {
        ts.at(ref).at(num) = "CHANGED"
    }
    ts
  })
  context [#state("test_state").get()]
}

Why is the form you chose not working? Each time you get(), Typst has to access the state value, which depends on the current context. If you then use that value to update(), you’re actually invalidating get() calls that come later in the document. This leads to a cascade:

  • Non-contextual parts of your document are evaluated: the state still has its initial values since all updates are contextual.
  • All get()s in your document run and fetch the state value. Since the update()s are not applied yet all get() calls see the initial value.
  • All update()s are inserted into the document, setting a value that is based on the get() result (the initial value).
  • Typst sees that all get()s but the first used the wrong value (there’s a new update there) so the second and all following context blocks are reevaluated, using the value after the first update
  • Typst sees that all get()s but the first and second used the wrong value so the third and all following context blocks are reevaluated, using the value after the second update.
  • After five updates, Typst gives up: the document did not “converge” in few enough attempts. The result of the final() call that you see is only based on the updates that were embedded into the document before then.

update(x => ..) circumvents the cascade and makes update values available immediately, which is why it makes the document converge!