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.
Observed with
@solidjs/signals/solid-js2.0.0-rc.13, and withnextat 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)andlatest(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)orisPending(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
untrackchanges nothing. Onnextevery variant changes the answer, untracked ones included, but differently: an outsideisPending(source)readsfalsewhile B is still parked.Repro
Results
The effect's log up to
| A resolved. Dev and prod builds give the same output.rc.13
B flushed/slow1, then/slow2at B's flushisPending=true latest=/slow2untrack(() => latest(source))untrack(() => isPending(source))latest(source)/slow1, then/slow2only after A resolvesisPending=true latest=/slow2isPending(source)/slow2only after A resolvesisPending=true latest=/slow2next (ba67ddb)
/slow1, then/slow2at B's flushB flushed: isPending=trueuntrack(() => latest(source))B flushed: isPending=false(B is still parked)untrack(() => isPending(source))B flushed: isPending=falselatest(source)/slow1, then/slow2at B's flushB flushed: isPending=falseisPending(source)/slow1, then/slow2after B's flush, not in itA parked: isPending=falsewhile the effect shows/slow1;A resolved: isPending=falseExpected
What one reader observes about a superseding write does not depend on whether the parked computation also reads
isPending/latestof 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 outsideisPending(source)read istruefor 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
latestshadow). 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.laneAsyncPendingrecords it in the lane's_pendingAsync, andlaneHeldthen 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. Onnextthe hold-model rewrite changed this path, and untracked reads now matter too.Router context
This surfaced while moving
@solidjs/routercoordination offisPending/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 trackedlatest(location)read inside a route's data fetch.