diff --git a/tests/release/packages/solid-reactivity/.gitignore b/tests/release/packages/solid-reactivity/.gitignore new file mode 100644 index 0000000000..8861e2a33d --- /dev/null +++ b/tests/release/packages/solid-reactivity/.gitignore @@ -0,0 +1,3 @@ +/out +/*.log +/*-out.txt diff --git a/tests/release/packages/solid-reactivity/README.md b/tests/release/packages/solid-reactivity/README.md new file mode 100644 index 0000000000..6ee2f59cfa --- /dev/null +++ b/tests/release/packages/solid-reactivity/README.md @@ -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. diff --git a/tests/release/packages/solid-reactivity/entry.ts b/tests/release/packages/solid-reactivity/entry.ts new file mode 100644 index 0000000000..caa14d4984 --- /dev/null +++ b/tests/release/packages/solid-reactivity/entry.ts @@ -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({ + 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(); diff --git a/tests/release/packages/solid-reactivity/expected.txt b/tests/release/packages/solid-reactivity/expected.txt new file mode 100644 index 0000000000..cfccf83928 --- /dev/null +++ b/tests/release/packages/solid-reactivity/expected.txt @@ -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 diff --git a/tests/release/packages/solid-reactivity/fixture.sh b/tests/release/packages/solid-reactivity/fixture.sh new file mode 100755 index 0000000000..2f97ec7aab --- /dev/null +++ b/tests/release/packages/solid-reactivity/fixture.sh @@ -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 diff --git a/tests/release/packages/solid-reactivity/package-lock.json b/tests/release/packages/solid-reactivity/package-lock.json new file mode 100644 index 0000000000..ba2d9fd35b --- /dev/null +++ b/tests/release/packages/solid-reactivity/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "perry-release-fixture-solid-reactivity", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-solid-reactivity", + "version": "0.0.0", + "dependencies": { + "solid-js": "1.9.15" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/solid-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz", + "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" + } + } + } +} diff --git a/tests/release/packages/solid-reactivity/package.json b/tests/release/packages/solid-reactivity/package.json new file mode 100644 index 0000000000..71469be13b --- /dev/null +++ b/tests/release/packages/solid-reactivity/package.json @@ -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" + } + } +}