Skip to content

L2: mount triggered by latest() shows content beside the committed value #3851

Description

@ryansolid

Summary

Inside an action, a <Show when={latest(x) > 0}> opens at once, which is expected, because latest() is display-ahead. But the new <Loading> it mounts shows content 1 while everything else on screen still shows the committed x = 0. Under the lane rule, a verdict lane's mounts stay mainline, so the mounted content belongs to the screen's world, not the proposal's. This is a torn frame.

Repro (packages/signals/tests, vitest)

import { expect, it } from "vitest";
import {
  action,
  createLoadingBoundary,
  createMemo,
  createRenderEffect,
  createRoot,
  createSignal,
  flush,
  latest,
  untrack
} from "../src/index.js";

const settle = async () => {
  for (let i = 0; i < 3; i++) {
    await new Promise(r => setTimeout(r, 0));
    flush();
  }
};

it("a mount triggered by latest() shows its content beside the committed value", async () => {
  const [x, setX] = createSignal(0);
  let screenX: unknown;
  let slot: unknown;
  createRoot(() => {
    // <p>{x()}</p>
    createRenderEffect(x, v => {
      screenX = v;
    });
    // <Show when={latest(x) > 0}>
    //   <Loading fallback="fallback">content {x()}</Loading>
    // </Show>
    const when = createMemo(() => latest(x) > 0);
    const children = createMemo(() =>
      when()
        ? untrack(() =>
            createLoadingBoundary(
              () => `content ${x()}`,
              () => "fallback"
            )
          )
        : "closed"
    );
    createRenderEffect(
      () => {
        const c = children();
        return typeof c === "function" ? c() : c;
      },
      v => {
        slot = v;
      }
    );
  });
  flush();
  expect([screenX, slot]).toEqual([0, "closed"]);

  let release!: () => void;
  const done = action(function* () {
    setX(1);
    yield new Promise<void>(r => (release = r));
  })();
  await settle();
  // The action holds x = 1; the screen still shows x = 0.
  expect(screenX).toBe(0);
  // Actual: "content 1" beside the committed 0.
  expect(slot).toBe("fallback");

  release();
  await done;
  await settle();
  expect([screenX, slot]).toEqual([1, "content 1"]);
});

The repro fails at expect(slot).toBe("fallback") with Received: "content 1". The same thing happens when the content is a memo, createMemo(() => \content ${x()}`)`.

Expected vs actual

Checkpoint Expected Actual
Before the action x = 0, slot closed x = 0, slot closed
Action holds x = 1 x = 0, slot fallback x = 0, slot content 1
Action ends x = 1, slot content 1 x = 1, slot content 1

Rules involved

  • The lane rule: an optimistic lane sees the screen plus its own guesses, and its mounts' children are the lane's. A verdict lane computes the real outcome, and its mounts stay mainline. Here the Show condition is verdict-lane work, and its children are seated in that lane. The new Loading's first pass inherits the lane from the pass that created it, so it reads the proposal x = 1.
  • A29's boundary scope (2026-10-06): a loading boundary that has not shown content owns its subtree. Content that reads a hold waits behind the boundary's fallback, and it appears at the hold's commit.
  • No tearing: content on screen agrees with the screen's value of what it reads.

The suspected site is the first-pass lane rule in recompute (core/core.ts). A first pass takes its creator's lane, which is right for an optimistic lane (ruling A). For a verdict lane, it should stay mainline.

Where it reproduces

Provenance

Found by the semantic fuzzer's mount-under-hold cohort, verdict family (fuzz/semantic-fuzzer-l2, b2bdf125a, rules MH1 and MH5). It probably shares a root with the read-order mount family (fuzzer cases 827, 416 and 936). There, a latest() read routes a freshly mounted child into the verdict lane, its mount control publishes alone, and the slot stays empty. In both cases a pass picks up verdict-lane membership that the lane rule says stays mainline: through a read in the read-order family, and through its creator here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions