Skip to content

Add Context.before() and Context.interrupt() - #374

Open
brainkim wants to merge 6 commits into
mainfrom
feat/stale-hook
Open

Add Context.before() and Context.interrupt()#374
brainkim wants to merge 6 commits into
mainfrom
feat/stale-hook

Conversation

@brainkim

@brainkim brainkim commented Jul 25, 2026

Copy link
Copy Markdown
Member

Closes #324. Supersedes #365.

Adds two lifecycle hooks. #324 was originally one hook, and most of the discussion on it was really the two of them pulling against each other. They turn out to be unrelated: one is about unfinished work, the other about the DOM.

fires when for
interrupt(callback?) a render is abandoned before it finishes tearing down work that render started
before(callback) a re-render is about to mutate the DOM reading state the commit will destroy

Context.interrupt(callback?)

async function *UserProfile(this: Context, {userId}) {
  for await ({userId} of this) {
    yield <div>Loading…</div>;
    const controller = new AbortController();
    this.interrupt(() => controller.abort());
    const user = await fetchUser(userId, {signal: controller.signal});
    yield <div>{user.name}</div>;
  }
}

A component is interrupted when it is abandoned mid-execution — a re-render superseded it, or it unmounted while still in flight. One callback covers both cases.

Callbacks are disarmed at the component's next checkpoint: the point where the runtime receives a value from it, meaning a yield, a return, or a resolved async function component. Work that reaches a checkpoint completed, so it is never interrupted. Two consequences fall out of that:

  • Code that should run for every render belongs inline after the yield. interrupt is only for the abandoned case.
  • Sync components never interrupt. A sync render reaches its checkpoint before control returns to the renderer, so it cannot be abandoned. That is a proof the hook can't fire, not a gap in coverage — a sync generator that kicks off a fetch outside the render flow keeps it.

Called with no argument, interrupt() returns a promise that resolves if the render is interrupted.

Why the checkpoint and not the commit

The first cut disarmed callbacks when the render committed. That is dead on arrival for async generators. The commit is deferred to a microtask, but the runtime resumes the generator body synchronously in the same pass:

yield <Spinner />;                          // (A) runtime receives this, schedules its commit
this.interrupt(() => controller.abort());   // (B) body resumes and runs — still the same tick
await fetch(url, {signal});                 // (C) parks here
                                            // ...microtasks drain, (A)'s commit runs

Under discard-on-commit, (A)'s commit wipes the registration made at (B). Moving the registration doesn't help — before (A) the same commit wipes it, after (A) it hasn't run yet. There is no reachable arming point in an async generator body at all.

The deferred commit is load-bearing (it is how a for await...of generator avoids blocking while its children render), so this works with the existing ordering rather than changing it. "Has this landed in the DOM?" and "has this execution finished?" are genuinely different moments; interrupt wants the second, and the checkpoint is already synchronous with the yield.

Context.before(callback)

function *Feed(this: Context) {
  let scrollTop = 0;
  for ({} of this) {
    this.before((el) => (scrollTop = el.scrollTop));
    this.schedule((el) => (el.scrollTop = scrollTop));
    yield <div class="feed">{...}</div>;
  }
}

Fires as a re-render commits — after the new children are diffed, before anything in the component's subtree touches the DOM. The callback receives the current, pre-mutation value, which is the last moment it reflects the previous render. This is the moment React exposes as getSnapshotBeforeUpdate: scroll offsets, focus, text selection, the value of an uncontrolled input.

Unlike React, there is no need to return the snapshot and thread it to a second callback — a local variable in the generator scope already does that.

  • Does not fire on the initial render. There is nothing to capture yet. Callbacks registered during the first render are discarded, not deferred; deferring them would make them fire against the second render alongside its own.
  • No promise-returning form. The value is the live node, which the commit mutates in place. A promise would resolve in a microtask, after the DOM had already moved on, carrying a node that shows the new render. The snapshot has to be read synchronously, so the callback is required. There is a test pinning this.

Tests

test/interrupt.tsx (8) and test/before.tsx (11).

For interrupt: doesn't fire when the render commits, or on unmount after a committed render; fires when superseded before commit, and when unmounting with a render in flight; at most once per registration; never triggers a re-render; the AbortController case on a suspended async generator; the zero-arg promise form.

For before: doesn't fire on the initial render; fires on re-render with the DOM still holding the previous output; receives the pre-mutation value; fires once per registration without accumulating; discards an initial-render registration; never triggers a re-render; captures a scroll offset and an uncontrolled input value; the async generator for await path; a parent re-render pushing new props down; and the value being accurate only during the call.

Full core suite 596 passing / 3 skipped. tsc --noEmit clean, eslint clean.

Notes

  • Add the before lifecycle hook (#324) #365 is superseded, not revived. Despite its title it did not implement this before(). It fired at the two points interrupt() now fires, and its example was the AbortController one — it was the abort hook under the other name.
  • Lifecycle events (Lifecycle events on the Context EventTarget #373) stay separate. Three of its four rows map to shipped hooks; crankbeforerender can now be pinned to this before(). interrupt gets no event: the events name moments every render passes through, and interrupt is the negation — the moment a render fails to reach one.

🤖 Generated with Claude Code

https://claude.ai/code/session_019pggktip8wsuxzy2VCY9p7

Fires when a render is retired — superseded by a re-render or removed on
unmount — so one callback tears down the retired render's in-flight async
work (e.g. aborting a fetch) in both cases, instead of needing before +
cleanup. The callback receives a promise for the successor render's result:
it resolves with the next value on re-render, or undefined on unmount. The
zero-arg form returns that promise directly.

This supersedes the `before` hook: renamed for what it does (indicate
staleness — "before what?" was always confusing), extended to fire on
unmount, and given a meaningful payload instead of void.

Verified: the pre-emptive fire cancels in-flight async work in an async
generator suspended mid-await (new test), unmount fires with an undefined
successor, and the successor promise carries the next render's result.
Full core suite green (585).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No clear use case yet for the successor value, so the callback takes no
argument and retire() resolves with void. Still fires on retirement —
re-render or unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brainkim brainkim changed the title Add Context.stale(): a retirement lifecycle hook (#324) Add Context.retire(): a retirement lifecycle hook (#324) Jul 26, 2026
brainkim and others added 2 commits July 26, 2026 21:24
after() fires once for the render it was registered in, then is removed
— same as schedule/cleanup/retire. The old 'every render' wording was
wrong and misleading. Also clarify schedule vs after by DOM timing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
retire() fired whenever a render was superseded or unmounted, which
conflated two unrelated things: tearing down work that never finished,
and running code around renders that completed just fine. The second is
a before()-shaped need (pre-mutation DOM snapshots — scroll, focus,
selection) and is tracked separately on #324.

interrupt() keeps only the first. Callbacks are disarmed at the
component's next checkpoint — the yield or return where the runtime
receives a value from it — so work that reaches its checkpoint is never
interrupted, and sync components, which always do, never interrupt at
all.

Disarming happens at the checkpoint rather than at commit because an
async generator resumes synchronously after a yield while that yield's
commit is deferred to a microtask. Disarming at commit would wipe
callbacks registered by the execution that follows the yield, leaving no
reachable arming point in an async generator at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brainkim brainkim changed the title Add Context.retire(): a retirement lifecycle hook (#324) Add Context.interrupt() for tearing down abandoned renders Aug 2, 2026
before() fires as a re-render commits, after the new children have been
diffed but before anything in the component's subtree touches the DOM. It
receives the current (pre-mutation) value, so a component can capture
state the commit is about to destroy — a scroll offset, focus, a
selection, the value of an uncontrolled input — and restore it from
schedule() or after().

Callbacks registered during a render fire as that render commits, reading
the output of the one before it. The initial commit has no previous
output, so its callbacks are discarded rather than fired; deferring them
would make them fire against the second render alongside its own.

There is deliberately no promise-returning form. The value is the live
node, which the commit mutates in place, so a promise would resolve in a
microtask after the DOM had already moved on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brainkim brainkim changed the title Add Context.interrupt() for tearing down abandoned renders Add Context.before() and Context.interrupt() Aug 12, 2026
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.

Add before lifecycle hook

1 participant