Skip to content

test: compile() inside a scheduler-driven system + the call-once pattern#89

Merged
andymai merged 1 commit into
mainfrom
test/compile-in-system
Jun 8, 2026
Merged

test: compile() inside a scheduler-driven system + the call-once pattern#89
andymai merged 1 commit into
mainfrom
test/compile-in-system

Conversation

@andymai
Copy link
Copy Markdown
Owner

@andymai andymai commented Jun 8, 2026

What

compile() (like bindColumns) is a call-once API — it codegens per-archetype runners — but a SystemDef has only a per-frame run and no init hook. The idiomatic pattern is to lazily build the runner on the first frame and cache it in the system's closure. This PR locks that in with an integration test and documents it.

let move: ((ctx: { dt: number }) => void) | null = null
const Movement = defineSystem({
  name: 'Movement', read: [Velocity], write: [Position],
  run({ query, dt }) {
    move ??= query(read(Velocity), write(Position)).compile<{ dt: number }>((e, ctx) => {
      e.position.x += e.velocity.dx * ctx.dt
      e.position.y += e.velocity.dy * ctx.dt
    })
    move({ dt })
  },
})

Why

compile() was property-tested at the bare-query level (#86) but never exercised in its primary execution context — a system under createScheduler. This closes that gap and answers the natural "how do I use a call-once API in a per-frame run?" question. (Worker-eligible systems run a separately-authored kernel on workers; compile is a main-thread run-body tool, exactly like each/bindColumns — confirmed while investigating.)

Changes

  • new packages/scheduler/test/compile-in-system.integration.test.ts — a lazily-cached compiled runner (a) integrates correctly across frames under scheduler.update(), built exactly once; (b) matches an equivalent .each system byte-for-byte; (c) a .changed(Position) consumer sees the compiled writes every frame (reactivity holds through the scheduler).
  • performance.md — "Inside a system" subsection with the run ??= query(...).compile(...) pattern.

Test plan

  • new test passes (3/3)
  • pnpm typecheck:tests, pnpm docs:check

compile() (like bindColumns) is a call-ONCE API, but a SystemDef has only a
per-frame `run` and no init hook. The idiomatic pattern is to lazily build the
runner on the first frame and cache it in the system's closure. This locks that
in end-to-end and documents it.

- new packages/scheduler/test/compile-in-system.integration.test.ts: a
  lazily-cached compiled runner integrates correctly across frames under
  scheduler.update() (built exactly once), matches an equivalent .each system
  byte-for-byte, and a .changed(Position) consumer sees the compiled writes
  every frame (reactivity holds through the scheduler).
- performance.md: "Inside a system" subsection showing the `run ??= query(...).compile(...)`
  lazy-cache pattern + the note that compile() is a main-thread run-body tool
  (worker-eligible systems run their separately-authored kernel on workers).
@andymai andymai enabled auto-merge (squash) June 8, 2026 10:15
@andymai andymai merged commit cdee2bc into main Jun 8, 2026
9 checks passed
@andymai andymai deleted the test/compile-in-system branch June 8, 2026 10:17
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 8, 2026

Greptile Summary

This PR adds an integration test and documentation for the compile() call-once pattern inside a defineSystem run body, closing a coverage gap between the bare-query property tests and real scheduler-driven usage.

  • New test (compile-in-system.integration.test.ts): three cases — lazy caching built exactly once across frames, byte-for-byte parity with .each, and .changed() reactivity surviving through the scheduler — all passing.
  • New docs section (performance.md "Inside a system"): explains the run ??= query(...).compile(...) idiom; the example currently places the runner cache at module scope, which is silently shared if the same system constant is used with multiple worlds.

Confidence Score: 4/5

Safe to merge for single-world usage; the module-level runner cache in the docs example warrants a note or pattern change before it teaches users a multi-world footgun.

The test logic is correct and well-isolated. The doc example's module-level let move silently reuses the first world's compiled runner for any second world sharing the same system constant — a pattern users are likely to reach for in integration tests.

The 'Inside a system' example in website/guide/performance.md deserves a second look for the module-scope runner cache.

Important Files Changed

Filename Overview
packages/scheduler/test/compile-in-system.integration.test.ts New integration test covering compile() inside a scheduler-driven system; logic is sound but local CompileQuery type hard-codes element shapes rather than flowing from the public Query interface.
website/guide/performance.md Documents the lazy-compile-in-system pattern; the example places the runner cache at module scope, creating a silent cross-world sharing hazard.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
website/guide/performance.md:209-238
**Module-level runner silently shared across worlds**

The example declares `let move` outside `defineSystem`, so a single `Movement` constant reuses the first world's compiled runner for every subsequent world that runs it. The runner is bound to the first world's typed-array columns; applying it to a different world's entities reads and writes the wrong memory. The idiomatic fix is to move the lazy initializer inside a factory function so each invocation captures its own slot.

### Issue 2 of 2
packages/scheduler/test/compile-in-system.integration.test.ts:13-17
**Local `CompileQuery` type diverges from the public `Query` interface**

The public `Query<Terms>` interface in `@ecsia/schema` already exposes `compile<Ctx>(body)`, typed against the inferred `QueryElement<Terms>`. The local `CompileQuery` alias hard-codes the element shape, so a schema change would allow the test to compile and pass while the body silently omits the new field. Using the public `Query` type from `@ecsia/schema` would catch such drift at compile time.

Reviews (1): Last reviewed commit: "test: compile() inside a scheduler-drive..." | Re-trigger Greptile

Comment on lines +209 to 238
### Inside a system

A `defineSystem` has only a per-frame `run` — no separate setup step — so build the runner on the first
frame and cache it in the system's closure (the same pattern applies to `bindColumns`). The query you
need is the one `run` receives, so the lazy build is the natural place for it:

```ts no-check
let move: ((ctx: { dt: number }) => void) | null = null

const Movement = defineSystem({
name: 'Movement',
read: [Velocity],
write: [Position],
run({ query, dt }) {
move ??= query(read(Velocity), write(Position)).compile<{ dt: number }>((e, ctx) => {
e.position.x += e.velocity.dx * ctx.dt
e.position.y += e.velocity.dy * ctx.dt
})
move({ dt })
},
})
```

The runner is built once, on the first frame, then reused — and because `compile` preserves the write
log, a `.changed()`/observer system later in the schedule still sees the writes. (A worker-eligible
system runs its separately-authored kernel on worker threads; `compile` is a main-thread `run`-body
tool, exactly like `each` and `bindColumns`.)

## Reproduce

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level runner silently shared across worlds

The example declares let move outside defineSystem, so a single Movement constant reuses the first world's compiled runner for every subsequent world that runs it. The runner is bound to the first world's typed-array columns; applying it to a different world's entities reads and writes the wrong memory. The idiomatic fix is to move the lazy initializer inside a factory function so each invocation captures its own slot.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/guide/performance.md
Line: 209-238

Comment:
**Module-level runner silently shared across worlds**

The example declares `let move` outside `defineSystem`, so a single `Movement` constant reuses the first world's compiled runner for every subsequent world that runs it. The runner is bound to the first world's typed-array columns; applying it to a different world's entities reads and writes the wrong memory. The idiomatic fix is to move the lazy initializer inside a factory function so each invocation captures its own slot.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +13 to +17
type CompileQuery = {
compile<Ctx>(body: (e: { position: { x: number; y: number }; velocity: { dx: number; dy: number } }, ctx: Ctx) => void): (
ctx: Ctx,
) => void
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Local CompileQuery type diverges from the public Query interface

The public Query<Terms> interface in @ecsia/schema already exposes compile<Ctx>(body), typed against the inferred QueryElement<Terms>. The local CompileQuery alias hard-codes the element shape, so a schema change would allow the test to compile and pass while the body silently omits the new field. Using the public Query type from @ecsia/schema would catch such drift at compile time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/scheduler/test/compile-in-system.integration.test.ts
Line: 13-17

Comment:
**Local `CompileQuery` type diverges from the public `Query` interface**

The public `Query<Terms>` interface in `@ecsia/schema` already exposes `compile<Ctx>(body)`, typed against the inferred `QueryElement<Terms>`. The local `CompileQuery` alias hard-codes the element shape, so a schema change would allow the test to compile and pass while the body silently omits the new field. Using the public `Query` type from `@ecsia/schema` would catch such drift at compile time.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant