Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 45 additions & 45 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ A resting optimistic node reports pending via exactly the causes a plain async m
### A15. Transition entanglement is graph-driven; lanes settle as one reveal

**Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass)
**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first)
**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first); `tests/posture-born-held-and-observation.test.ts` (posture matrix, ruled 2026-09-15: `latest(x)` evaluated inside another live action ENTANGLES — "optimistic lanes are transition-bound, so it does need to entangle; that doesn't mean optimism for both can't poke through in the meanwhile" — the held value is served at once, the two transactions reveal as one)
**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo another live transaction holds (stamped, and pending or staged) enters it (`initTransition(sub._transition)`, #3443).

(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction — when the memo is genuinely _held_ (pending on that transaction's work, or staged by it); a stamp alone decides nothing (#3334), so a second write that supersedes the first through a shared output memo does not drag the superseded flight into the live reveal; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`.
Expand Down Expand Up @@ -294,6 +294,20 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H

`ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending.

## Open rulings

### O1. Same-tick adoption — an action adopts the ambient writes made before it in the same tick

**Status:** **ruled, by design** 2026-09-15 — maintainer: "transitions are ambient. Action is just a special case to link them over async; every write is a conceptual transition." Surfaced by the posture matrix (`tests/visibility-oracle-posture.test.ts`, class A: 16 cells across the `staged, ambient` and `override, ambient` states under the `foreignAction` / `foreignLane` postures); pinned by `tests/posture-born-held-and-observation.test.ts` (an ambient write made before an action in the same tick reveals with the action).
**Current behavior:** `initTransition` adopts the ambient batch wholesale (`_pendingNodes`, `_optimisticNodes`, `_affectsNodes`, gated readers), so `set(x, 1); startAction()` in one tick makes `x`'s plain write the action's — `x()` stays 0 until the unrelated action settles, and inside the action's body `latest(x)` answers 1 and `isPending(x)` true (A28 would say 0 / false pre-flush for a write nothing holds). An ambient optimistic write made before the action (`setO(5); startAction()`), which mainline reverts at the next flush (OL-R5), instead lives on as the action's override for its whole lifetime.
**Ruling:** intended. The same-tick window is one ambient transition; an action that opens in it owns it. (#3141 ruled the ASYNC-GAP version a bug — work arriving after the body's first await is not the action's.) Not React's `startTransition`, deliberately.

### O2. Creation under a transaction escapes the hold — recorded, not ruled

**Status:** **recorded** 2026-09-15 — maintainer: "generally creation escapes because it isn't visible"; "I'm fine either way as long as we end up somewhere consistent"; "let's capture things as they are." Pinned as OBSERVED by `tests/posture-born-held-and-observation.test.ts`; the matrix (class B, 10 cells) records it.
**Current behavior:** a memo + render effect created inside a live action's body over a value another action holds (or over the superseded / body-ended states) direct-commits (`recompute`'s `create && bornHeld === null` arm — `stagedEntry` is only recorded when `activeTransition` is null) and its render effect publishes the held value, while untracked reads keep the committed frame until both actions settle. Mainline creation over the same value is born held (A29's creation-time form, 2026-09-14). Creation inside the HOLDING action's own body is unaffected: the body's write is unflushed (A28), the creation reads the committed frame.
**Tension:** A29 (born held) applies to mainline creation only; the "isn't visible" premise does not hold for a render effect created in the body. A future ruling either extends A29 to every posture (`creatingPass` in `enterStagedRead`, prototyped 2026-09-15: +83 B, suite green) or states creation-escapes as the rule and re-examines the mainline form.

## Superseded rules (kept verbatim)

Cited by tests and by A24's reasoning; the statements below are as they stood when superseded.
Expand Down
123 changes: 123 additions & 0 deletions packages/signals/tests/posture-born-held-and-observation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Posture matrix pins (visibility-oracle-posture.test.ts is the discovery
* runner; these are the cells brought to a ruling, 2026-09-15).
*
* A — same-tick adoption is BY DESIGN: transitions are ambient; an action is
* the special case that links one across async, and every write is a
* conceptual transition. An action started later in the same tick owns
* the ambient writes made before it.
* C — latest() ENTANGLES (A15): optimistic lanes are transition-bound, so a
* latest() read inside another live action merges that action with the
* source's — "that doesn't mean optimism for both can't poke through in
* the meanwhile": the value is served immediately, the reveal is joint.
* B — creation under a transaction ESCAPES the hold (spec O2, recorded as
* current behavior, not ruled): a memo + render effect created inside a
* live action over a value another action holds direct-commits and
* publishes the held value while untracked reads keep the committed
* frame. "Generally creation escapes because it isn't visible" — the
* matrix shows the creation's own effect does publish; kept as is
* ("capture things as they are", 2026-09-15). A change here flips the
* pin loudly and is a design decision.
*/
import { describe, expect, it } from "vitest";
import {
action,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest
} from "../src/index.js";

const tick = async () => {
await Promise.resolve();
await Promise.resolve();
flush();
};

/** `x` held at 1 by action T (yields until `release`). */
function heldByAction() {
const [x, setX] = createSignal(0);
let release!: () => void;
const run = action(function* () {
setX(1);
yield new Promise<void>(r => (release = r));
});
run();
flush();
expect(x()).toBe(0);
return { x, release };
}

describe("A — same-tick adoption is by design: transitions are ambient (posture matrix, ruled 2026-09-15)", () => {
it("an action started after an ambient write in the same tick owns that write; it reveals with the action", async () => {
const [x, setX] = createSignal(0);
setX(1); // ambient, unflushed
let release!: () => void;
const run = action(function* () {
yield new Promise<void>(r => (release = r));
});
run();
flush();
expect(x()).toBe(0); // adopted: held with the action, not committed by the flush
release();
await tick();
expect(x()).toBe(1);
});
});

describe("C — latest() entangles: lanes are transition-bound (A15, ruled 2026-09-15)", () => {
it("latest(x) inside another live action serves the held value now and merges that action with x's transaction", async () => {
const { x, release } = heldByAction();
const [y, setY] = createSignal(0);
let seen: number | undefined;
let releaseU!: () => void;
const runU = action(function* () {
setY(1);
seen = latest(x); // optimism pokes through immediately …
yield new Promise<void>(r => (releaseU = r));
});
runU();
flush();
expect(seen).toBe(1);
release(); // … but T alone no longer reveals: U and T settle as one
await tick();
expect(x()).toBe(0);
expect(y()).toBe(0);
releaseU();
await tick();
expect(x()).toBe(1);
expect(y()).toBe(1);
});
});

describe("B — creation under a transaction escapes the hold (OBSERVED, spec O2; not a ruling)", () => {
it("a memo + render effect created inside another live action over a held value publishes the held value; the frame keeps the committed one until both actions settle", async () => {
const { x, release } = heldByAction();
const [y, setY] = createSignal(0);
const published: number[] = [];
let releaseU!: () => void;
const runU = action(function* () {
setY(1);
createRoot(() => {
const m = createMemo(() => x());
createRenderEffect(m, v => {
published.push(v);
});
});
yield new Promise<void>(r => (releaseU = r));
});
runU();
flush();
expect(published).toEqual([1]); // observed: the creation direct-committed the held value
expect(x()).toBe(0); // while the frame reads committed
release(); // the creation derived from T's world, so U merged into T (A15): T alone does not reveal
await tick();
expect(x()).toBe(0);
releaseU();
await tick();
expect(x()).toBe(1);
expect(published).toEqual([1]);
});
});
Loading
Loading