Add Context.before() and Context.interrupt() - #374
Open
brainkim wants to merge 6 commits into
Open
Conversation
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>
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>
# Conflicts: # docs/SKILL.md
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
interrupt(callback?)before(callback)Context.interrupt(callback?)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, areturn, or a resolved async function component. Work that reaches a checkpoint completed, so it is never interrupted. Two consequences fall out of that:yield.interruptis only for the abandoned case.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:
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...ofgenerator 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;interruptwants the second, and the checkpoint is already synchronous with the yield.Context.before(callback)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.
Tests
test/interrupt.tsx(8) andtest/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; theAbortControllercase 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 generatorfor awaitpath; 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 --noEmitclean, eslint clean.Notes
beforelifecycle hook (#324) #365 is superseded, not revived. Despite its title it did not implement thisbefore(). It fired at the two pointsinterrupt()now fires, and its example was theAbortControllerone — it was the abort hook under the other name.crankbeforerendercan now be pinned to thisbefore().interruptgets no event: the events name moments every render passes through, andinterruptis the negation — the moment a render fails to reach one.🤖 Generated with Claude Code
https://claude.ai/code/session_019pggktip8wsuxzy2VCY9p7