Skip to content

isPending/latest on a superseded transition depend on whether the parked async memo also reads them #3884

Description

@ryansolid

Observed with @solidjs/signals / solid-js 2.0.0-rc.13, and with next at ba67ddb. Context: solidjs/solid-router#655.

Problem

A signal is written (A) and an async memo parks on it. In a later tick a second write (B) supersedes A. A render effect reports isPending(source) and latest(source).

When B is flushed, that effect should move to B, whatever other computations read. It does when nothing else reads the verdicts. If the async memo that A is waiting on also reads latest(source) or isPending(source) itself, the effect stays on A until A's async resolves. The effect's code is identical in every variant; only the async memo changes.

On rc.13 only a tracked read inside the parked memo does this; the same read wrapped in untrack changes nothing. On next every variant changes the answer, untracked ones included, but differently: an outside isPending(source) reads false while B is still parked.

Repro

import {
  createMemo, createRenderEffect, createRoot, createSignal,
  flush, isPending, latest, untrack
} from "@solidjs/signals";

const tick = () => new Promise(r => setTimeout(r, 0));

async function run(extra) {
  const log = [];
  const gates = {};
  const gate = k => (gates[k] ??= Promise.withResolvers());
  const [source, setSource] = createSignal("/");

  createRoot(() => {
    const path = createMemo(() => source());
    // parks on "/slow*" until its gate resolves
    const data = createMemo(() => {
      const v = path();
      if (extra === "untracked latest") untrack(() => latest(source));
      if (extra === "untracked isPending") untrack(() => isPending(source));
      if (extra === "tracked latest") latest(source);
      if (extra === "tracked isPending") isPending(source);
      return v.startsWith("/slow") ? gate(v).promise : v;
    });
    createRenderEffect(data, () => {});

    // the UI reader: identical in every variant
    const target = createMemo(() => (isPending(source) ? latest(source) : "-"));
    createRenderEffect(target, v => void log.push(`effect: ${v}`));
  });
  flush();

  const checkpoint = label =>
    log.push(`| ${label}: isPending=${isPending(source)} latest=${latest(source)}`);

  setSource("/slow1"); // A
  flush();
  checkpoint("A parked");
  await tick();
  setSource("/slow2"); // B supersedes A, in a later tick
  flush();
  checkpoint("B flushed");
  await tick();
  gates["/slow1"].resolve("one");
  await tick();
  checkpoint("A resolved");
  gates["/slow2"].resolve("two");
  await tick();
  checkpoint("B resolved");

  console.log(`async memo also reads: ${extra}\n  ${log.join("\n  ")}\n`);
}

for (const extra of ["nothing", "untracked latest", "untracked isPending", "tracked latest", "tracked isPending"])
  await run(extra);

Results

The effect's log up to | A resolved. Dev and prod builds give the same output.

rc.13

async memo also reads effect outside reads at B flushed
nothing /slow1, then /slow2 at B's flush isPending=true latest=/slow2
untrack(() => latest(source)) same as nothing same
untrack(() => isPending(source)) same as nothing same
latest(source) /slow1, then /slow2 only after A resolves isPending=true latest=/slow2
isPending(source) never shows A as pending; /slow2 only after A resolves isPending=true latest=/slow2

next (ba67ddb)

async memo also reads effect outside reads
nothing /slow1, then /slow2 at B's flush B flushed: isPending=true
untrack(() => latest(source)) same as nothing B flushed: isPending=false (B is still parked)
untrack(() => isPending(source)) same as nothing B flushed: isPending=false
latest(source) /slow1, then /slow2 at B's flush B flushed: isPending=false
isPending(source) /slow1, then /slow2 after B's flush, not in it A parked: isPending=false while the effect shows /slow1; A resolved: isPending=false

Expected

What one reader observes about a superseding write does not depend on whether the parked computation also reads isPending/latest of the same source. Either every variant matches the "nothing" row, or the delay is a documented consequence of reading a verdict inside a held computation. And an outside isPending(source) read is true for as long as B is parked.

The question for core: is a latest() read (tracked or not) inside a computation that takes part in the transition supposed to hold or delay other readers' view of the superseding write?

Where it seems to come from (rc.13)

I haven't instrumented this; I'm going by the source. With a tracked read, the parked memo subscribes to the verdict companions (the pending signal and the latest shadow). B's write updates those companions (syncCompanions), which re-runs the parked memo on the companion's lane (optimistic.ts L755–766). It parks again there. laneAsyncPending records it in the lane's _pendingAsync, and laneHeld then holds every reader on that lane, including the unrelated UI effect, until the async it reports on resolves. That is close to the shape of #3409. Pre-creating the companions before A changes nothing, so it isn't about when they are created. On next the hold-model rewrite changed this path, and untracked reads now matter too.

Router context

This surfaced while moving @solidjs/router coordination off isPending/latest. With identical hook code, the per-link pending marker moved from A's link to B's link at different times (solidjs/solid-router#655). On rc.13 the difference came from a tracked latest(location) read inside a route's data fetch.

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