Skip to content

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

Closed

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

  1. ryansolid commented on Oct 7, 2026

    @ryansolid
    MemberAuthor

    Fixed in #3869 (merged as 11e9fb6). A mount triggered by latest() is no longer seated in the verdict lane by default; only a verdict lane's mount over a staged read is born held, so new content no longer shows beside the committed value. The spec amendment to A29 and L2 statement 3 is in the same PR, along with regression tests.

    — Claude via Cursor

  2. ethan-huo commented on Oct 20, 2026

    @ethan-huo

    The manually assembled signals pin and public compiled JSX shape still differ on current next. Existing conformance case 056 remains red on both published RC15 and source-built current next 2172a37687e92dd91758476423f08cb4a12bcb0c with the matching native compiler and system Chrome.

    During the hold, the outer committed binding stays 0 while the newly mounted Loading displays inner content 1; the expected intermediate state is outer 0 plus pending fallback. Both reach 1 after release, so the failure is the intermediate visible tear rather than non-convergence or a final multiple-text-locator assertion. No Router, async source or type bypass is needed.

    Current HEAD raw result, fresh RC15 result. This is the already-recorded compiled Show/Loading reproduction of this semantic family; I am following up here rather than opening a duplicate issue. Could the compiled JSX form be retained alongside the primitive regression pin?

  3. ethan-huo commented on Oct 20, 2026

    @ethan-huo

    Follow-up: the missing shape can now be reproduced entirely at the signals level, without a compiler or browser. The existing pin reads x directly in the boundary's content expression; the failing DOM form creates a child createRenderEffect(x, ...) there, with the two condition memos used by Show. The new pin observes [outer 0, content node, inner 1] during the hold instead of [outer 0, pending, undefined].

    A small core-only candidate carries verdict-mount provenance through intermediate content computations while their first passes execute, restores it in the existing finally, and uses it in the born-held staged-read decision. Descendants remain mainline; the repair does not seat them in the verdict lane or change the compiler. On current next, the fresh-boundary hook also needs the propagated provenance so it does not classify this mount as the held pass's own frame.

    HEAD source diff + failing signals pin, qualification and RC15-specific diff, raw receipts.

    • Fixed next 2172a37687e92dd91758476423f08cb4a12bcb0c: the new signals pin and original public compiled JSX case go red to green; compiled case passes in development and production. Full signals suite with the new pin: 324 files, 5388 passed, 3 expected failures, 2 skips, Node / Vitest 4.1.6.
    • RC15 with upstream fix(signals): a memo over a verdict memo shows the verdict beside a held value (U-10, #4050) #4065 backported plus the version-specific local proposal: original compiled case passes in development and production; full signals suite 302 files, 5215 passed, 20 expected failures, 2 skips.
    • Both graphs pass 80 strict development Chrome property cases across static/function/keyed children, nested Errored and different staged values, with no known-finding exemption. Source build types pass. Reapplying the exported diffs recreates the qualified source exactly.

    This is a local source proposal for review, not a claim of upstream acceptance or complete SSR/hydration coverage. The original conformance test stays unpatched and red. Could this child-render-effect pin and provenance repair be reviewed alongside the original primitive fix?

  4. ethan-huo commented on Oct 20, 2026

    @ethan-huo

    The uncovered DOM/render-effect path is now tracked separately in #4166, with the minimal JSX repro, signals-only pin, candidate source repair and RC15/next validation. Linking it here for continuity with the original fix; follow-up review can use the open issue.

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