Skip to content

Add the before lifecycle hook (#324)#365

Open
brainkim wants to merge 3 commits into
mainfrom
feat/before-hook
Open

Add the before lifecycle hook (#324)#365
brainkim wants to merge 3 commits into
mainfrom
feat/before-hook

Conversation

@brainkim

Copy link
Copy Markdown
Member

Closes #324.

Adds Context.before(callback) — a lifecycle hook that fires immediately before a component re-renders, and only on re-renders (not the initial render). It lets a component cancel in-flight async work from the previous render before it becomes stale.

Use case

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

When userId changes, before fires and aborts the stale fetch before the loop re-iterates.

Semantics

  • Fires once per registration — re-register on each render, like schedule.
  • Never itself triggers a re-render (unlike refresh/schedule/after).
  • Fires synchronously when a re-render is requested, not when the component re-runs — so the abort is prompt even while the component is blocked on the very async work being cancelled.

Implementation

A beforeMap keyed by context state (mirroring scheduleMap/cleanupMap), plus runBeforeCallbacks(ctx) invoked at the two synchronous re-render request points: diffComponent's update branch (prop changes / parent re-renders) and refresh(). Callbacks are cleared after firing.

Tests

test/before.tsx covers: no-fire on initial render; ordering before re-render (refresh + prop change); once-per-registration; no self-triggered re-render; the AbortController use case; and the async-generator for await path. Full suite green (580); tsc --noEmit clean.

🤖 Generated with Claude Code

@brainkim brainkim mentioned this pull request Jun 24, 2026
7 tasks
`Context.before(callback)` registers a callback that fires immediately
before a component re-renders, and only on re-renders (not the initial
render). Useful for cancelling in-flight async work from the previous
render before it goes stale — e.g. aborting a fetch via AbortController.

Re-register it each render. Unlike `refresh`, `schedule`, and `after`, it
never itself triggers a re-render.

@brainkim brainkim left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@claude some thoughts

Comment thread src/crank.ts Outdated
Comment thread src/crank.ts
Addresses review on #365: the verbose runBeforeCallbacks name and its
commentary weren't pulling their weight. The two call-sites (an explicit
refresh and a mounted re-render) stay — the initial-mount path
deliberately doesn't fire before callbacks, so it can't fold into
enqueueComponent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread docs/SKILL.md Outdated

```js
this.refresh(callback?) // Mutate state and re-render
this.before(callback) // Run before the next re-render (e.g. abort stale async work)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@claude One of these methods is not like the other one (zero-arg promise form)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right — verified in the impl. schedule/after/cleanup each carry a second overload where the zero-arg call returns Promise<TResult> and resolves with the rendered value at that lifecycle moment (their callbacks take (value: TResult)). before is before(callback: () => unknown): void — single signature, and its callback takes no value, because it fires before any render exists. So there is no TResult to resolve a promise with; a before(): Promise could only resolve with void, which is the asymmetry rather than a fix for it. (And await this.before() would largely duplicate for await...of this as a “wait for the next update” signal.)

So it is a genuine fork, your call:

  1. Keep it callback-only and make the doc asymmetry intentional (drop the implied ?, maybe a one-word note).
  2. Add before(): Promise<void> for surface symmetry, accepting the void resolution.

The SKILL.md line is accurate to (1) as written. Say which way and I’ll align the doc (and the overload if you want 2).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in abb5f9f — added the before(): Promise<void> overload (mirrors the schedule idiom: new Promise((resolve) => this.before(() => resolve()))). Updated the SKILL.md signature to before(callback?), the CHANGELOG, and added a test asserting it stays pending until a re-render then resolves with undefined. The lone remaining asymmetry — resolving with void rather than the rendered value — is inherent, since nothing has rendered yet.

Matches schedule/after/cleanup: calling before() with no argument now
returns a promise that resolves just before the next re-render. It
resolves with no value, since no render has happened yet — the one way it
still differs from the others, which resolve with the rendered value.

Updates the SKILL.md signature and CHANGELOG accordingly, plus a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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