Skip to content
Closed
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
3 changes: 3 additions & 0 deletions tests/release/packages/solid-reactivity/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/out
/*.log
/*-out.txt
91 changes: 91 additions & 0 deletions tests/release/packages/solid-reactivity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Solid client-runtime audit

This is the first prerequisite for the native UI bridge in [#4644](https://github.com/PerryTS/perry/issues/4644).
It compiles the installed, unmodified Solid 1.9.15 core, store, and universal
renderer to native code and compares their output with Node. No display server
or JavaScript runtime is needed by the resulting binary.

```sh
PERRY_BIN=/absolute/path/to/perry \
tests/release/packages/_harness.sh --filter solid-reactivity
```

Use the repository's `.node-version` for the oracle. The release sweep's
package tier discovers this fixture automatically. Its lockfile pins Solid
and its transitive dependencies; `fixture.sh` uses `npm ci` on a fresh checkout.

The fixture exercises:

- Signals, memo invalidation, batched updates, equality suppression, and disposal.
- Dynamic effect dependencies and cleanup before reruns and disposal.
- Store proxies, nested reads, updater functions, `produce`, keyed `reconcile`,
preserved item identity, and `unwrap`.
- The real universal renderer over an in-memory host: keyed insertion,
anchored reordering, replacement, removal, owner cleanup, and text updates
that preserve node identity.

## Selecting the reactive build

Solid's `node` export selects its server implementation. That implementation
does not subscribe effects to updates. Perry's normal Node-compatible package
resolution therefore needs these existing project aliases for a reactive app:

```json
{
"perry": {
"compilePackages": ["solid-js"],
"allow": { "compilePackages": ["solid-js"] },
"packageAliases": {
"solid-js": "solid-js/dist/solid.js",
"solid-js/store": "solid-js/store/dist/store.js"
}
}
}
```

The core alias also applies to imports inside the store and universal renderer,
so they share the same owner and dependency state. Importing the client core
only in the app would leave those internal imports pointing at the server
build. The Node oracle uses `--conditions=browser` to select the corresponding
client exports.

This fixture does not establish a `perry/ui` adapter, native event handling,
or a Solid JSX compiler mode. It also does not audit resources, transitions,
hydration, or every store operation. Solid's bundled `h` and `html` entry
points use its web renderer; they are not a native hyperscript API. See
[Solid's universal-renderer contract](https://github.com/solidjs/solid/blob/main/packages/solid/universal/README.md)
for the host operations and the separate universal JSX transform.

## Moving-GC verification finding

On main `d36a1af0c`, the normal fixture matches the Node oracle. A forced
copying run with seed 4644 and protected from-space also matches (22 copying
minors, 14,022 moved objects on macOS arm64). Enabling the evacuation verifier
exposes a failure during store creation:

```sh
PERRY_GC_SCHEDULE_SEED=4644 PERRY_GC_SCHEDULE_RATE=1 \
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 \
tests/release/packages/solid-reactivity/out
```

The verifier reports `stale forwarded pointer in remembered dirty ranges` at
the fifth scheduled collection. A store-only reduction also reproduces a
stale pointer in `heap fields`. The signal-only probe passes verification.
This finding needs resolution before declaring the native bridge's GC audit
complete; normal output parity alone does not resolve it.

Further isolation identified the reported field as the effect computation's
`sources` array after growing from capacity 8 to 16. Both the old forwarding
stub and its target are tenured. Array growth deliberately retains such
aliases for `clean_arr_ptr` to follow, whereas the verifier currently rejects
any forwarded reference. The follow-up needs to distinguish those retained
growth aliases from evacuation originals that are about to be reclaimed.

The correction is proposed in [#9822](https://github.com/PerryTS/perry/pull/9822).
With that change, this fixture matches all 20 oracle lines with both protected
from-space and evacuation verification enabled: seed 4644/rate 1 completes
22 copying minors and moves 14,022 objects. Seeds 1/rate 0.25 and 42/rate 0.1
also pass, completing six and one copying minors respectively. The runtime
regressions also check that direct and indirect nursery forwarding references
are still rejected.
145 changes: 145 additions & 0 deletions tests/release/packages/solid-reactivity/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// #4644: exercise the installed client runtime, including the shared owner
// and listener state used by solid-js/store and solid-js/universal.
import {
batch, createComponent, createMemo, createRenderEffect, createRoot,
createSignal, For, onCleanup, untrack,
} from "solid-js";
import { createStore, produce, reconcile, unwrap } from "solid-js/store";
import { createRenderer } from "solid-js/universal";

const state = createRoot(dispose => {
const [count, setCount] = createSignal(1);
const doubled = createMemo(() => count() * 2);
const values: number[] = [];
createRenderEffect(() => { values.push(doubled()); });
onCleanup(() => console.log("signal cleanup"));
return { count, setCount, doubled, values, dispose };
});
console.log("initial", state.count(), state.doubled(), state.values.join(","));
state.setCount(2);
console.log("update", state.count(), state.doubled(), state.values.join(","));
batch(() => { state.setCount(3); state.setCount(4); });
state.setCount(4); // Equal writes must not notify.
console.log("batch", state.count(), state.doubled(), state.values.join(","));
console.log("untrack", untrack(state.count));
state.dispose();
state.setCount(5);
console.log("disposed", state.values.join(","));

const branch = createRoot(dispose => {
const [left, setLeft] = createSignal(true);
const [a, setA] = createSignal(1);
const [b, setB] = createSignal(10);
const seen: number[] = [];
let cleanups = 0;
createRenderEffect(() => {
seen.push(left() ? a() : b());
onCleanup(() => { cleanups++; });
});
return { setLeft, setA, setB, seen, cleanups: () => cleanups, dispose };
});
branch.setB(11); // The inactive dependency is not subscribed.
branch.setA(2);
branch.setLeft(false);
branch.setA(3); // The old dependency must have been removed.
branch.setB(12);
console.log("branches", branch.seen.join(","), branch.cleanups());
branch.dispose();
console.log("branch cleanup", branch.cleanups());

const store = createRoot(dispose => {
const [value, setValue] = createStore({
user: { name: "Ada", score: 1 },
items: [{ id: 1, value: "one" }, { id: 2, value: "two" }],
});
const seen: string[] = [];
createRenderEffect(() => {
seen.push(value.user.name + ":" + value.user.score + ":" +
value.items.map(item => item.value).join(","));
});
return { value, setValue, seen, dispose };
});
console.log("store", store.seen.join("|"));
store.setValue("user", "score", score => score + 1);
store.setValue("user", produce(user => { user.name = "Grace"; }));
console.log("store updates", store.seen.join("|"));
const first = store.value.items[0];
store.setValue("items", reconcile([{ id: 2, value: "TWO" }, { id: 1, value: "ONE" }]));
console.log("reconcile", store.seen.join("|"));
console.log("store identity", first === store.value.items[1]);
console.log("unwrap", unwrap(store.value).user.name);
store.dispose();
store.setValue("user", "score", 9);
console.log("store disposed", store.seen.length);

// An in-memory host keeps this audit independent of a display server. The
// actual Solid renderer drives these operations, including anchored moves.
interface HostNode {
kind: string;
value: string;
children: HostNode[];
parent: HostNode | undefined;
}
function hostNode(kind: string, value = ""): HostNode {
return { kind, value, children: [], parent: undefined };
}
function remove(parent: HostNode, node: HostNode): void {
const index = parent.children.indexOf(node);
if (index < 0) throw new Error("removing a non-child");
parent.children.splice(index, 1);
node.parent = undefined;
}
const renderer = createRenderer<HostNode>({
createElement: kind => hostNode(kind),
createTextNode: value => hostNode("text", value),
replaceText: (node, value) => { node.value = value; },
setProperty: (node, name, value) => {
if (name !== "value") throw new Error("unexpected property " + name);
node.value = String(value);
},
insertNode(parent, node, anchor) {
if (node === anchor) return;
if (node.parent) remove(node.parent, node);
const index = anchor ? parent.children.indexOf(anchor) : parent.children.length;
if (index < 0) throw new Error("anchor is not a child");
parent.children.splice(index, 0, node);
node.parent = parent;
},
isTextNode: node => node.kind === "text",
removeNode: remove,
getParentNode: node => node.parent,
getFirstChild: node => node.children[0],
getNextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1],
});

const root = hostNode("root");
const [rows, setRows] = createSignal(["a", "b", "c"]);
let creations = 0;
let rowCleanups = 0;
const disposeRows = renderer.render(() => createComponent(For, {
get each() { return rows(); },
children: item => {
creations++;
onCleanup(() => { rowCleanups++; });
return renderer.createTextNode(item);
},
}), root);
const originalA = root.children[0];
console.log("rows", root.children.map(node => node.value).join(","));
setRows(["c", "a", "b"]);
console.log("moved", root.children.map(node => node.value).join(","), root.children[1] === originalA, creations);
setRows(["b", "d"]);
console.log("replaced", root.children.map(node => node.value).join(","), creations, rowCleanups);
setRows([]);
console.log("empty", root.children.map(node => node.value).join(","), rowCleanups);
disposeRows();
setRows(["after disposal"]);
console.log("rows disposed", creations, rowCleanups);

const labelRoot = hostNode("root");
const [label, setLabel] = createSignal("before");
const disposeLabel = renderer.render(() => label, labelRoot);
const originalLabel = labelRoot.children[0];
setLabel("after");
console.log("text update", labelRoot.children[0].value, labelRoot.children[0] === originalLabel);
disposeLabel();
20 changes: 20 additions & 0 deletions tests/release/packages/solid-reactivity/expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
initial 1 2 2
update 2 4 2,4
batch 4 8 2,4,8
untrack 4
signal cleanup
disposed 2,4,8
branches 1,2,11,12 3
branch cleanup 4
store Ada:1:one,two
store updates Ada:1:one,two|Ada:2:one,two|Grace:2:one,two
reconcile Ada:1:one,two|Ada:2:one,two|Grace:2:one,two|Grace:2:TWO,ONE
store identity true
unwrap Grace
store disposed 4
rows a,b,c
moved c,a,b true 3
replaced b,d 4 2
empty 4
rows disposed 4 4
text update after true
21 changes: 21 additions & 0 deletions tests/release/packages/solid-reactivity/fixture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
[[ "${1:-}" == "--__did-skip-marker" ]] && exit 1
cd "$(dirname "$0")"
source ../_fixture_lib.sh
if [[ ! -d node_modules ]]; then
npm ci --ignore-scripts --no-audit --no-fund
fi
fixture_setup "solid-reactivity"

# Solid's default Node export is intentionally non-reactive SSR. Use the
# client condition for the oracle, matching packageAliases in package.json.
node --conditions=browser entry.ts > node-out.txt
diff -u expected.txt node-out.txt
# A whole-build cache hit emits no module census. Keep object-cache reuse,
# but perform module collection so the native-only assertion is meaningful.
PERRY_DISABLE_BUILD_CACHE=1 fixture_compile_run_diff "solid-reactivity"
if ! grep -Eq 'Found [0-9]+ module\(s\): [1-9][0-9]* native, 0 JavaScript' perry-compile.log; then
echo "FAIL solid-reactivity — expected every module to compile natively"
exit 1
fi
53 changes: 53 additions & 0 deletions tests/release/packages/solid-reactivity/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions tests/release/packages/solid-reactivity/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "perry-release-fixture-solid-reactivity",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Native Solid core, store, and universal-renderer audit for #4644",
"dependencies": {
"solid-js": "1.9.15"
},
"perry": {
"compilePackages": ["solid-js"],
"allow": { "compilePackages": ["solid-js"] },
"packageAliases": {
"solid-js": "solid-js/dist/solid.js",
"solid-js/store": "solid-js/store/dist/store.js"
}
}
}
Loading