diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..185ff96182 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/react/preact-upstream-tests/preact"] + path = packages/react/preact-upstream-tests/preact + url = https://github.com/hzy/preact.git + branch = lynx/v10.24.x diff --git a/.typos.toml b/.typos.toml index dd9d79bff9..c026c36464 100644 --- a/.typos.toml +++ b/.typos.toml @@ -15,6 +15,8 @@ extend-exclude = [ "packages/web-platform/web-tests/tests/react.spec.ts", "packages/web-platform/web-tests/tests/react/basic-element-x-overlay-ng-playground-test/index.jsx", "packages/web-platform/offscreen-document/src/webworker/OffscreenCSSStyleDeclaration.ts", + # Upstream Preact submodule — do not spell-check third-party content + "packages/react/preact-upstream-tests/preact/**", ] [default] @@ -31,3 +33,5 @@ nd = "nd" bui = "bui" ba = "ba" alog = "alog" +# "apppend" is an intentional typo in an upstream Preact test name that we mirror in our skiplist +apppend = "apppend" diff --git a/eslint.config.js b/eslint.config.js index 6e5e773d9d..3d352ad374 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,7 @@ export default tseslint.config( // testing-library 'packages/testing-library/**', 'packages/react/testing-library/**', + 'packages/react/preact-upstream-tests/**', // gesture-runtime-testing 'packages/lynx/gesture-runtime/__test__/**', diff --git a/packages/react/preact-upstream-tests/.github/skiplist.instructions.md b/packages/react/preact-upstream-tests/.github/skiplist.instructions.md new file mode 100644 index 0000000000..b13bb1067a --- /dev/null +++ b/packages/react/preact-upstream-tests/.github/skiplist.instructions.md @@ -0,0 +1,8 @@ +--- +applyTo: "{skiplist.json,vitest.shared.ts,README.md,package.json}" +--- + +When updating skiplist categories, always run each selected group in both projects (`preact-upstream` and `preact-upstream-compiled`) with `SKIPLIST_ONLY=:` before moving entries. +Keep mode orthogonality explicit: shared failures stay in `skip_list`/`permanent_skip_list`, no-compile-only failures go to `nocompile_skip_list`, compiled-only failures go to `compiler_skip_list`. +Because skip matching is title-based, check for duplicate test titles across files before removing an entry; a single title may map to multiple test cases with different outcomes. +In README positioning and run-order guidance, treat compiled mode as the primary product-path confidence signal and describe no-compile mode as a runtime baseline/regression-isolation tool. diff --git a/packages/react/preact-upstream-tests/README.md b/packages/react/preact-upstream-tests/README.md new file mode 100644 index 0000000000..6079ce1361 --- /dev/null +++ b/packages/react/preact-upstream-tests/README.md @@ -0,0 +1,489 @@ +# Preact Upstream Tests: E2E Pipeline Verification + +Run Preact's own test suite through the **ReactLynx dual-threaded rendering pipeline** +to verify semantic alignment between "Preact rendering to the Web" and +"Preact rendering to Lynx through the Snapshot -> Element PAPI path". + +## Dual-Mode Testing + +The same test suite runs in **two modes** via Vitest workspace: + +| Mode | Project Name | What Preact Sees | Primary Purpose | +| ------------- | -------------------------- | ------------------------------------------------ | ----------------------------------------- | +| With compiler | `preact-upstream-compiled` | `{ values: ['foo'] }` via SWC snapshot transform | **Primary product-path confidence** | +| No compiler | `preact-upstream` | Raw props `{ className: 'foo' }` | Runtime baseline and regression isolation | + +The compiler is **conservative** (like Vue 3's compiler-hinted Virtual DOM) — it only +optimizes what it can statically analyze. As an optimization, it should **not change +program semantics**. Both modes' `scratch.innerHTML` should be identical for any given +test. A discrepancy = a bug in the compiler or runtime. + +**Current results (compiled)**: 413 pass / 0 fail / 192 skip (across 605 tests in 36 files) +**Current results (no-compiler)**: 438 pass / 0 fail / 167 skip (across 605 tests in 36 files) + +### Mode Positioning + +- **Default signal for release confidence**: compiled mode (`preact-upstream-compiled`). +- **Baseline and triage tool**: no-compiler mode (`preact-upstream`). +- If a test fails in compiled mode but passes in no-compiler mode, treat it as a likely + compiler/snapshot-path regression. +- If a test fails in both modes, treat it as a shared runtime/pipeline gap. + +No-compiler mode is retained to validate runtime fundamentals independent of the SWC transform: + +1. Raw-props path behavior (`setProperty` semantics through BSI shims) +2. Generic snapshot fallback behavior (unknown tag create/update without compiled artifacts) +3. Shared dual-thread runtime stability (commit, patch apply, scratch sync timing) + +> Skiplist categories are mode-orthogonal: +> +> - `skip_list` + `permanent_skip_list`: shared by both modes +> - `nocompile_skip_list`: applied only in `preact-upstream` +> - `compiler_skip_list`: applied only in `preact-upstream-compiled` + +## Goals and Non-Goals + +### Goal: High-Level Operational Semantics + +The purpose of this test suite is to confirm that `@lynx-js/react`'s use of Preact +preserves the high-level semantics of upstream Preact: + +- Component lifecycle (mount, update, unmount) +- Reconciliation / diffing (keyed reordering, fragment handling, conditional rendering) +- Context propagation (`createContext`, nested providers, consumer updates) +- State management (`setState` batching, callbacks, functional updates) +- Ref forwarding and callback refs +- Error boundaries +- `shouldComponentUpdate` / `PureComponent` behavior + +Tests that **pass** = alignment confirmed. +Tests that **fail** = semantic gap that needs investigation. + +### Non-Goal: Web DOM Specifics + +Lynx is not a web browser. Many Preact tests assert behaviors that depend on web-specific +APIs and conventions that are **structurally absent** in the Lynx platform. These tests are +**non-goals** — they don't tell us anything about whether our Preact fork preserves the +rendering semantics we care about. + +Categories of non-goals (managed via `skiplist.json`): + +1. **DOM mutation order** (`getLog`/`clearLog`): Tests that assert the exact sequence + of `appendChild`/`removeChild`/`insertBefore` calls. The Lynx pipeline routes through + a `` root element, producing structurally different (but semantically equivalent) + mutation sequences. (~77 tests) + +2. **`dangerouslySetInnerHTML`**: Lynx Element PAPI has no `innerHTML` equivalent. + (~7 tests) + +3. **`MutationObserver`**: Lynx environment does not provide this API. (~2 tests) + +4. **Web DOM IDL properties**: Preact's `setProperty()` sets DOM properties like + `element.value`, `element.checked`, `element.contentEditable` directly (`dom[prop] = val`). + BSI has no IDL property layer, so these take a different code path. (~9 tests) + +5. **Boolean-to-attribute serialization**: Web convention (`true->''`, `false->remove`) + vs Lynx convention — not relevant to Element PAPI. (~3 tests) + +6. **JSON serialization limits**: `NaN`, `BigInt`, objects with custom `toString()` are + lost during `JSON.stringify` in the BSI->patch IPC boundary. (~6 tests) + +7. **`component.base` / Refs as DOM nodes**: Tests that expect `component.base` or refs + to return real DOM nodes. In Lynx, these return BSI (background thread objects). (~14 tests) + +8. **Direct DOM mutation**: Tests that mutate jsdom directly and expect Preact to detect + the change — BSI has no access to main-thread DOM state. (permanent skip) + +9. **Event registration** (`events.test.js`): Tests spy on DOM `element.addEventListener` + but BSI event stubs register on the background thread and never reach jsdom. Lynx has + its own event model separate from the Web DOM event system. (permanent skip) + +10. **Focus / selection** (`focus.test.js`): Tests call `element.focus()` and + `setSelectionRange()` via `document.activeElement`. Lynx has its own focus model. + (permanent skip, except `should maintain focus when hydrating` which passes in no-compiler mode) + +11. **Dual-thread lifecycle DOM timing**: Tests that read DOM state synchronously inside + lifecycle hooks (`componentWillMount`, `getSnapshotBeforeUpdate`, etc.). In the dual-thread + model the background thread cannot observe main-thread DOM state mid-commit. (~5 tests) + +12. **Preact internals** (`getDomSibling.test.js`): Tests import and directly invoke + Preact's internal `getDomSibling()` algorithm, walking `dom._children` VNode attachment. + Our pipeline renders to `globalThis.__root` (BSI), not to `scratch`, so `scratch._children` + is always `undefined`. These test Preact's internal algorithm, not rendering semantics. + (excluded entirely) + +13. **`replaceNode` parameter** (`replaceNode.test.js`): Tests pre-populate jsdom with + raw HTML and then call `render(jsx, container, replaceNode)` to reuse/replace specific + nodes. Requires `dom._children` internal VNode state and is web-specific (no SSR reuse + in Lynx). (excluded entirely) + +## Test Subsetting via `skiplist.json` + +Inspired by the Hermes test runner, we use a structured `skiplist.json` to declaratively manage +which tests are excluded and why. + +### Structure + +```jsonc +{ + // Keyword-based: scan each it() body for these keywords, auto-skip if found + "unsupported_features": [ + { + "keywords": ["getLog", "clearLog"], + "comment": "DOM mutation order — ...", + "skipped_count": 77 // approximate, for documentation + } + ], + + // Manual: skip specific tests by exact name match + "skip_list": [ + { + "tests": ["test name 1", "test name 2"], + "comment": "Reason these are skipped" + } + ], + + // No-compiler-only: fails in preact-upstream, passes in preact-upstream-compiled + "nocompile_skip_list": [ + { + "tests": ["test name"], + "comment": "No-compile specific gap" + } + ], + + // Compiler-only: fails in preact-upstream-compiled, passes in preact-upstream + "compiler_skip_list": [ + { + "tests": ["test name"], + "comment": "Compile-only semantic gap" + } + ], + + // Permanent: fundamentally incompatible, never expected to pass + "permanent_skip_list": [ + { + "tests": ["test name"], + "comment": "Reason this will never work" + } + ] +} +``` + +### How It Works + +A Vite transform plugin (`preact-skiplist` in `vitest.config.ts`) processes each test +file at build time: + +1. **Keyword scanning**: For each `it()` block, the plugin extracts the full body text + and checks against `unsupported_features[].keywords` using word-boundary regex. + If any keyword matches, `it(` is rewritten to `it.skip(`. + +2. **Manual skip**: The test name (first string argument to `it()`) is checked against: + - shared: `skip_list` + `permanent_skip_list` + - no-compiler-only: `nocompile_skip_list` (only in `preact-upstream`) + - compiled-only: `compiler_skip_list` (only in `preact-upstream-compiled`) + Exact match -> `it.skip(`. + +This approach means: + +- **No upstream test modifications** — the submodule stays pristine +- **Self-documenting** — every skip has a `comment` explaining why +- **Easy to audit** — `skipped_count` shows the blast radius of each keyword +- **Easy to evolve** — as Lynx gains features, remove entries and watch tests pass + +### Decision: `skip_list` vs `nocompile_skip_list` vs `compiler_skip_list` vs `permanent_skip_list` + +- **`skip_list`**: Tests that _could_ pass someday if we bridge the gap (e.g., BSI IDL + properties, JSON serialization). We keep them separate to track potential future work. +- **`nocompile_skip_list`**: Tests that fail only in no-compiler mode and already pass + in compiled mode. +- **`permanent_skip_list`**: Tests that are _structurally impossible_ in Lynx (e.g., + direct DOM mutation, `