diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12abede0..dbd3c2d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +# Framework compatibility is local-only and must never run in any CI job. +# Do not restore frameworks.yml. See tests/Frameworks/README.md. + on: push: branches: [main] diff --git a/.github/workflows/frameworks.yml b/.github/workflows/frameworks.yml deleted file mode 100644 index c75771a1..00000000 --- a/.github/workflows/frameworks.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Framework Compatibility - -on: - push: - branches: [main] - paths: - - 'src/**' - - 'tests/Frameworks/**' - - 'scripts/node-*/**' - - 'composer.json' - - 'composer.lock' - - '.github/workflows/frameworks.yml' - pull_request: - branches: [main] - paths: - - 'src/**' - - 'tests/Frameworks/**' - - 'scripts/node-*/**' - - 'composer.json' - - 'composer.lock' - - '.github/workflows/frameworks.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: frameworks-${{ github.ref }} - cancel-in-progress: true - -env: - XDEBUG_MODE: 'off' - TZ: UTC - -jobs: - upstream: - name: PHP ${{ matrix.php }} / ${{ matrix.suite }} - runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - php: ['8.4', '8.5'] - suite: [express, hono-main, hono-jsx-runtime-default, hono-jsx-runtime-dom, hono-node, http2] - steps: - - uses: actions/checkout@v5 - - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, bcmath, curl, intl, openssl, sodium, zlib, sockets - coverage: none - - - uses: actions/setup-node@v6 - with: - node-version: '26.12.0' - cache: npm - cache-dependency-path: tests/Frameworks/package-lock.json - - - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Install pinned dependencies - run: | - composer install --no-interaction --prefer-dist - npm ci --prefix tests/Frameworks - - - name: Prepare Express source - if: matrix.suite == 'express' - run: python3 -B tests/Frameworks/express/setup.py - - - name: Prepare Hono source - if: startsWith(matrix.suite, 'hono-') - run: python3 -B tests/Frameworks/hono/setup.py - - - name: Express original tests - if: matrix.suite == 'express' - run: | - python3 -B tests/Frameworks/express/test_runner.py - python3 -B tests/Frameworks/express/run.py --runtime both --jobs 1 --timeout-scale 10 --output framework-results/express - - - name: Hono harness deadline differential - if: startsWith(matrix.suite, 'hono-') - run: | - python3 -B tests/Frameworks/hono/test_runner.py - python3 -B tests/Frameworks/hono/selftest.py - - - name: Hono original runtime tests - if: startsWith(matrix.suite, 'hono-') - env: - FRAMEWORK_SUITE: ${{ matrix.suite }} - run: python3 -B tests/Frameworks/hono/run.py --project "${FRAMEWORK_SUITE#hono-}" --timeout 1800 --timeout-scale 10 - - - name: Hono independent native Vitest baseline - if: startsWith(matrix.suite, 'hono-') && !cancelled() - env: - FRAMEWORK_SUITE: ${{ matrix.suite }} - run: | - mkdir -p framework-results - if [ "$FRAMEWORK_SUITE" = 'hono-main' ]; then - node tests/Frameworks/hono/prepare-toolchain.mjs - fi - PHASIS_HONO_PROJECT="${FRAMEWORK_SUITE#hono-}" node tests/Frameworks/node_modules/vitest/vitest.mjs run \ - --config tests/Frameworks/hono/vitest.config.mjs \ - --reporter=json --outputFile="$PWD/framework-results/hono-native.json" - - - name: Compare every Hono case with native Vitest - if: startsWith(matrix.suite, 'hono-') && !cancelled() - run: | - python3 -B - <<'PY' - import json - from pathlib import Path - import subprocess - results = Path('tests/Frameworks/hono/results') - run_id = json.loads((results / 'manifest.json').read_text())['id'] - manifest = results / 'runs' / run_id / 'manifest.json' - statuses = [subprocess.run([ - 'python3', '-B', 'tests/Frameworks/hono/compare-native.py', - 'framework-results/hono-native.json', str(manifest), '--runtime', runtime, - ]).returncode for runtime in ['node', 'phasis']] - raise SystemExit(int(any(statuses))) - PY - - - name: Hono original declaration tests - if: matrix.suite == 'hono-main' && !cancelled() - run: python3 -B tests/Frameworks/hono/run-toolchain.py - - - name: HTTP2 native Node interoperability - if: matrix.suite == 'http2' - run: | - mkdir -p framework-results - node tests/Frameworks/http2/interop.mjs | tee framework-results/http2.jsonl - - - name: Preserve complete manifests and failure logs - if: always() - uses: actions/upload-artifact@v6 - with: - name: frameworks-php${{ matrix.php }}-${{ matrix.suite }}-${{ github.sha }} - path: | - framework-results/ - tests/Frameworks/hono/results/runs/ - if-no-files-found: warn diff --git a/.github/workflows/jsx.yml b/.github/workflows/jsx.yml index c13ee372..da8597cd 100644 --- a/.github/workflows/jsx.yml +++ b/.github/workflows/jsx.yml @@ -11,6 +11,7 @@ on: branches: [main] paths: - 'src/**' + - 'browser/**' - 'tests/Jsx/**' - 'phpunit.xml.dist' - 'composer.json' @@ -20,6 +21,7 @@ on: branches: [main] paths: - 'src/**' + - 'browser/**' - 'tests/Jsx/**' - 'phpunit.xml.dist' - 'composer.json' @@ -89,3 +91,37 @@ jobs: env: TZ: UTC run: node tests/Jsx/oracle/hydrate.mjs + + - name: Qualify the Composer hydration consumer + run: node tests/Jsx/oracle/hydration-contract.mjs + + hydration-runtime: + name: Hydration runtime PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + php: ['8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, bcmath, intl, curl + coverage: none + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: tests/Jsx/oracle/package-lock.json + + - name: Install the pinned browser toolchain + working-directory: tests/Jsx/oracle + run: npm ci --no-audit --no-fund + + # Only the downstream runtime is installed. PHPUnit requires newer PHP + # than our supported library minimum and is not a runtime dependency. + - name: Qualify the Composer hydration consumer + run: node tests/Jsx/oracle/hydration-contract.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6737704e..5b07546a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,8 @@ name: Release +# Framework compatibility must never run in CI and is not a release gate. +# See tests/Frameworks/README.md for the owner's local-only policy. + on: release: types: [published] @@ -13,6 +16,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.event.release.tag_name }} - uses: shivammathur/setup-php@v2 with: @@ -20,27 +25,20 @@ jobs: extensions: mbstring, bcmath, curl, intl tools: composer - - run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + - name: Stage the canonical runtime archive + run: | + mkdir -p dist/phasis + git archive HEAD | tar -x -C dist/phasis + composer install --working-dir=dist/phasis --no-dev --no-interaction --prefer-dist --optimize-autoloader - name: Verify autoloader run: | + cd dist/phasis php -r "require 'vendor/autoload.php'; class_exists('Phasis\\Engine', true) || exit(1); echo 'Autoloader OK';" php bin/phasis -e 'console.log(1 + 1)' | grep -q '^2$' - name: Create release archive run: | - mkdir -p dist - rsync -a --exclude='.git' --exclude='.github' --exclude='.claude' \ - --exclude='.compat-state' --exclude='.phpunit*' --exclude='.phpstan*' \ - --exclude='tests' --exclude='test262' --exclude='scenarios' \ - --exclude='bench' --exclude='docs' --exclude='prds' \ - --exclude='AGENTS.md' --exclude='CLAUDE.md' --exclude='BENCH.md' --exclude='bench.json' \ - --exclude='compat.json' --exclude='phpunit.xml*' --exclude='phpcs.xml' \ - --exclude='phpstan.neon*' \ - --exclude='bin/compat-*' --exclude='bin/build-*' --exclude='bin/gen-*' \ - --exclude='bin/test-regression' --exclude='bin/test262' \ - --exclude='bin/verify-all' \ - . dist/phasis/ cd dist && zip -r ../phasis.zip phasis/ - name: Upload release asset diff --git a/AGENTS.md b/AGENTS.md index 31c31838..3a6af123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,13 @@ run test oracles, but must never become an execution backend. Explicit through `process.execPath` runs the Phasis PHP entry point. New host resources must register cleanup with their owning NodeRuntime. Keep original framework assertions, report all discovered cases and upstream skips, and retain source -identity for complete-run claims. Express and Hono support is experimental; -their CI runners explicitly use `--timeout-scale 10` while performance work is -deferred. Preserve original timeout values and record effective limits in the +identity for complete-run claims. Express and Hono support is experimental. +Framework compatibility suites are local-only and must NEVER run in CI, +including manual workflow dispatches, pull requests, pushes or release gates. +Do not restore the removed `frameworks.yml` workflow or dispatch old revisions. +The owner's 2026-10-25 decision explicitly permits releases without this suite. +See [the framework policy](tests/Frameworks/README.md). Preserve original +timeout values and record effective limits in the results; a scaled run is a functional compatibility result, not a claim to meet upstream performance deadlines. Never relax test262 assertions, coverage or timeouts to accommodate framework workloads. Close and reap owned test diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b348dd..e52a60b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [0.10.0] - 2026-10-25 + +### Added +- `Engine::renderForHydration()` captures public JSON-data props before module execution and returns immutable matched React HTML, script-safe state and root metadata, without changing the existing rendering or callable interop APIs. +- Explicit hydration entry/build/root identities, named exports, React `createElement` semantics and matching `identifierPrefix` forwarding to native Fizz and the browser. +- Composer-shipped `browser/hydrate.mjs` and TypeScript declarations. The helper uses the consumer's peer React/React DOM pair, validates the captured transport and identities, rejects duplicate initialization, and preserves server HTML on transport failure. +- Downstream Composer archive tests covering development/production hydration, node reuse, connected imports/context/state, render-time mutation, safe data shapes and real React diagnostics on PHP 8.2 through 8.5. + +### Changed +- Release download archives use the same export-ignore runtime inventory as Composer before installing their bundled production autoloader. +- Framework compatibility is local-only and must never run in CI or gate a release. Its GitHub Actions workflow is removed and disabled. + +### Fixed +- Captured data preserves all public JSON keys, including names resembling native slots and leading null bytes. Native metadata readers keep those objects unbranded, so a public `[[PrimitiveValue]]` key cannot make them behave as boxed values. + +### Upgrade notes +- The new helper targets React/React DOM 19.3.1 and element roots. Full-document convenience transport and renderer nonce/CSP options are explicitly unsupported; existing document rendering remains available through `render()`. +- Only public data belongs in hydration props. Larger exact integers must be strings. WordPress loaders, application context, asset identities and cache policy remain the embedding application's responsibility. + ## [0.9.0] - 2026-10-24 ### Added diff --git a/COMPAT.md b/COMPAT.md index 1503a4f5..06485c9c 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -3,13 +3,13 @@ Generated from a completed `test262` pass. Do not edit by hand. - Refresh: `./bin/compat-report` -- Snapshot time: `2026-10-24T08:22:25+00:00` +- Snapshot time: `2026-10-25T10:38:39+00:00` - Chunk size: `25` - Timeout: `300s` - Jobs: `4` - Groups: `161` - Test files: `53616` -- Git: `feat/express-hono-compatibility` @ `b4624b71` (dirty) +- Git: `feat/react-hydration` @ `194f0635` (dirty) ## Summary @@ -198,26 +198,26 @@ Generated from a completed `test262` pass. Do not edit by hand. | Group | Kind | Duration | Files | Sample | |---|---|---:|---:|---| -| staging/sm | NORMAL | 187.532s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-7-of-8.js` | -| staging/sm | NORMAL | 185.335s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-4-of-8.js` | -| staging/sm | NORMAL | 183.376s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-8-of-8.js` | -| staging/sm | NORMAL | 181.430s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-3-of-8.js` | -| staging/sm | NORMAL | 176.894s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-2-of-8.js` | -| staging/sm | NORMAL | 167.859s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-5-of-8.js` | -| built-ins/decodeURIComponent | NORMAL | 164.085s | 1 | `test262/test/built-ins/decodeURIComponent/S15.1.3.2_A2.5_T1.js` | -| staging/sm | NORMAL | 156.742s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-6-of-8.js` | -| staging/sm | NORMAL | 152.821s | 1 | `test262/test/staging/sm/TypedArray/sort_large_countingsort.js` | -| staging/sm | NORMAL | 149.594s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-1-of-8.js` | -| built-ins/decodeURI | NORMAL | 144.082s | 1 | `test262/test/built-ins/decodeURI/S15.1.3.1_A2.5_T1.js` | -| staging/sm | NORMAL | 125.528s | 1 | `test262/test/staging/sm/TypedArray/element-setting-converts-using-ToNumber.js` | -| built-ins/decodeURIComponent | NORMAL | 74.418s | 25 | `test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.11_T2.js`
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.12_T1.js`
...
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.8_T1.js`
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.8_T2.js` | -| built-ins/decodeURI | NORMAL | 73.594s | 25 | `test262/test/built-ins/decodeURI/S15.1.3.1_A1.11_T2.js`
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.12_T1.js`
...
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T1.js`
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T2.js` | -| annexB/built-ins/RegExp | NORMAL | 64.136s | 25 | `test262/test/annexB/built-ins/RegExp/RegExp-control-escape-russian-letter.js`
`test262/test/annexB/built-ins/RegExp/RegExp-decimal-escape-class-range.js`
...
`test262/test/annexB/built-ins/RegExp/legacy-accessors/lastParen/this-cross-realm-constructor.js`
`test262/test/annexB/built-ins/RegExp/legacy-accessors/lastParen/this-not-regexp-constructor.js` | -| intl402/DateTimeFormat | NORMAL | 60.635s | 25 | `test262/test/intl402/DateTimeFormat/prototype/formatRangeToParts/temporal-plainmonthday-formatting-timezonename.js`
`test262/test/intl402/DateTimeFormat/prototype/formatRangeToParts/temporal-plaintime-formatting-timezonename.js`
...
`test262/test/intl402/DateTimeFormat/prototype/formatToParts/name.js`
`test262/test/intl402/DateTimeFormat/prototype/formatToParts/offset-timezone-correct.js` | -| intl402 | NORMAL | 59.788s | 22 | `test262/test/intl402/constructors-string-and-single-element-array.js`
`test262/test/intl402/constructors-taint-Object-prototype-2.js`
...
`test262/test/intl402/supportedLocalesOf-throws-if-element-not-string-or-object.js`
`test262/test/intl402/supportedLocalesOf-unicode-extensions-ignored.js` | -| built-ins/RegExp/property-escapes/generated | NORMAL | 59.566s | 5 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nandinagari.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_New_Tai_Lue.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Newa.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nko.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nushu.js` | -| built-ins/RegExp/property-escapes/generated | NORMAL | 59.490s | 5 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Inscriptional_Parthian.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Javanese.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kaithi.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kannada.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Katakana.js` | -| built-ins/RegExp/property-escapes/generated | NORMAL | 58.496s | 5 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Todhri.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Tolong_Siki.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Toto.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Tulu_Tigalari.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Ugaritic.js` | +| built-ins/decodeURIComponent | NORMAL | 161.263s | 1 | `test262/test/built-ins/decodeURIComponent/S15.1.3.2_A2.5_T1.js` | +| built-ins/decodeURI | NORMAL | 145.859s | 1 | `test262/test/built-ins/decodeURI/S15.1.3.1_A2.5_T1.js` | +| staging/sm | NORMAL | 100.749s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-7-of-8.js` | +| staging/sm | NORMAL | 98.881s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-4-of-8.js` | +| staging/sm | NORMAL | 96.500s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-2-of-8.js` | +| staging/sm | NORMAL | 94.585s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-3-of-8.js` | +| staging/sm | NORMAL | 91.265s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-8-of-8.js` | +| staging/sm | NORMAL | 83.485s | 1 | `test262/test/staging/sm/TypedArray/sort_large_countingsort.js` | +| staging/sm | NORMAL | 81.532s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-1-of-8.js` | +| staging/sm | NORMAL | 78.526s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-5-of-8.js` | +| staging/sm | NORMAL | 77.426s | 1 | `test262/test/staging/sm/Date/dst-offset-caching-6-of-8.js` | +| built-ins/decodeURI | NORMAL | 74.256s | 25 | `test262/test/built-ins/decodeURI/S15.1.3.1_A1.11_T2.js`
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.12_T1.js`
...
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T1.js`
`test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T2.js` | +| built-ins/decodeURIComponent | NORMAL | 72.042s | 25 | `test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.11_T2.js`
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.12_T1.js`
...
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.8_T1.js`
`test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.8_T2.js` | +| language/literals | NORMAL | 71.990s | 25 | `test262/test/language/literals/regexp/S7.8.5_A1.1_T1.js`
`test262/test/language/literals/regexp/S7.8.5_A1.1_T2.js`
...
`test262/test/language/literals/regexp/S7.8.5_A2.2_T2.js`
`test262/test/language/literals/regexp/S7.8.5_A2.3_T1.js` | +| staging/sm | NORMAL | 62.725s | 1 | `test262/test/staging/sm/TypedArray/element-setting-converts-using-ToNumber.js` | +| intl402 | NORMAL | 62.168s | 22 | `test262/test/intl402/constructors-string-and-single-element-array.js`
`test262/test/intl402/constructors-taint-Object-prototype-2.js`
...
`test262/test/intl402/supportedLocalesOf-throws-if-element-not-string-or-object.js`
`test262/test/intl402/supportedLocalesOf-unicode-extensions-ignored.js` | +| intl402/DateTimeFormat | NORMAL | 60.538s | 25 | `test262/test/intl402/DateTimeFormat/prototype/formatRangeToParts/temporal-plainmonthday-formatting-timezonename.js`
`test262/test/intl402/DateTimeFormat/prototype/formatRangeToParts/temporal-plaintime-formatting-timezonename.js`
...
`test262/test/intl402/DateTimeFormat/prototype/formatToParts/name.js`
`test262/test/intl402/DateTimeFormat/prototype/formatToParts/offset-timezone-correct.js` | +| built-ins/RegExp/property-escapes/generated | NORMAL | 59.903s | 3 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hanifi_Rohingya.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hanunoo.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hatran.js` | +| built-ins/RegExp/property-escapes/generated | NORMAL | 59.787s | 3 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tibetan.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tifinagh.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tirhuta.js` | +| built-ins/RegExp/property-escapes/generated | NORMAL | 59.667s | 3 | `test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kharoshthi.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Khitan_Small_Script.js`
`test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Khmer.js` | ## Runner Caveats diff --git a/README.md b/README.md index e64ae5cf..461349de 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Phasis lexes, parses, and executes ECMAScript in pure PHP. No `exec('node …')` - **Experimental Express and Hono support** through opt-in Node compatibility: CommonJS packages, Buffer, streams, filesystem access, HTTP/HTTPS/HTTP2 servers and clients, TLS, crypto, timers, and async context, executed inside PHP. See the [module coverage and host limits](./docs/modules.md#node-compatibility). - **JSX Pack**: render JSX components to HTML from PHP with `Engine::render()`, no build step - **Native React 19.3 server renderer** in PHP: `react`, `react-dom/server` and `react-dom/static` are built in, with HTML byte-identical to `react-dom@19.3.1` and hydratable by React on the client (hooks, context, Suspense, `useId`, async components, resource hints) + - **Captured-data hydration**: `Engine::renderForHydration()` pairs HTML with safe public props and a Composer-shipped browser helper; see the [data and browser contract](./docs/react.md#rendering-a-component-for-hydration) - **General HTML renderer** (`phasis`, `phasis/jsx-runtime`) for emails, feeds and static pages, byte-identical to preact-render-to-string 6.8.1, plus `raw()` - **Bundler-style imports**: extensionless paths and `index` files, `jsconfig` aliases, `node_modules` with `package.json` `exports`, CommonJS packages, style and asset imports, import maps, and barrel pruning for `sideEffects: false` packages - Optional precompile of static markup, an `opcache.preload` script, and an on-disk parse cache @@ -116,7 +117,7 @@ composer cs # Full quality gate (PHPStan + PHPCS + PHPUnit + oracle) ./bin/verify-all -# Original Express and Hono suites, with pinned development dependencies +# Local-only Express and Hono suites. NEVER run these in CI. npm ci --prefix tests/Frameworks python3 tests/Frameworks/express/setup.py python3 tests/Frameworks/hono/setup.py @@ -127,7 +128,7 @@ python3 tests/Frameworks/hono/run.py --timeout-scale 10 ./bin/test262 --category built-ins/Array --jobs 4 ``` -Express and Hono support is experimental. Framework CI uses ten times the upstream test timeouts while retaining the original assertions and recording both timeout values. Omit `--timeout-scale 10` to check the original deadlines. This setting does not change test262 limits or engine execution budgets. +Express and Hono support is experimental. **Framework compatibility suites must never run in CI**, including manual dispatches or release gates. The framework workflow is removed and disabled; releases do not require these suites. See the [local-only framework policy](./tests/Frameworks/README.md). Local runs may use `--timeout-scale 10` while retaining the original assertions and recording both timeout values. Omit it to check the original deadlines. This setting does not change test262 limits or engine execution budgets. CI runs PHPStan, PHPCS, the unit suite and the oracle regression on every push to `main` and every pull request; the Web Platform Tests and popular-package suites run when `src/` or their fixtures change. The full test262 matrix (`compat-matrix.yml`) is started by hand, not on every push; it runs against the test262 revision pinned by the submodule and commits the result to `COMPAT.md`, which records the snapshot time. diff --git a/browser/README.md b/browser/README.md new file mode 100644 index 00000000..13af95f4 --- /dev/null +++ b/browser/README.md @@ -0,0 +1,34 @@ +# Browser hydration + +This directory ships in `phasis/phasis`'s Composer archive. There is no Phasis +npm package. Resolve the helper's peer `react`, `react-dom` and `react-dom/client` +imports with the consumer's existing bundler, using one React/React DOM 19.3.1 +pair. `hydrate.d.mts` supplies TypeScript declarations. + +Render with `Engine::renderForHydration()` and explicit +`Phasis\Hydration\HydrationOptions`, then emit the returned `toHtml()`. From an +external browser entry in the project root: + +```js +import * as Components from './components/Root.jsx'; +import { hydrate } from './vendor/phasis/phasis/browser/hydrate.mjs'; + +hydrate(document.getElementById('site'), Components, { + entryId: 'site', + buildId: BUILD_ID, + onError: reportTransportError, + onRecoverableError: reportReactError, +}); +``` + +The entry/build/export identities must match PHP. The helper consumes the +adjacent inert state tag, validates its metadata and calls `hydrateRoot` with +the captured props and identifier prefix. Transport failures throw +`HydrationError` and preserve server HTML. React hydration failures use React's +diagnostic hooks. No payload-driven imports or `createRoot` fallback occur. + +Only element roots are qualified. Renderer nonce options and full-document +convenience transport are unsupported. Inert JSON alone does not qualify all +React-emitted instructions for a strict CSP. The host owns headers and caching. + +See the [complete PHP, data, transport and lifecycle contract](https://github.com/inline0/phasis/blob/main/docs/react.md). diff --git a/browser/hydrate.d.mts b/browser/hydrate.d.mts new file mode 100644 index 00000000..a00d4815 --- /dev/null +++ b/browser/hydrate.d.mts @@ -0,0 +1,22 @@ +import type { HydrationOptions, Root } from 'react-dom/client'; + +/** A transport or identity failure detected before React starts hydrating. */ +export declare class HydrationError extends Error { + readonly code: 'root' | 'duplicate' | 'state' | 'protocol' | 'identity' | 'react' | 'export' | 'diagnostics'; + constructor(code: HydrationError['code'], message: string); +} + +/** Identities compiled into the host's external browser entry and React diagnostics. */ +export interface ExpectedHydration extends Pick { + entryId: string; + buildId: string; + export?: string; + onError?: (error: HydrationError) => void; +} + +/** Hydrate using a statically imported module and the root's adjacent Phasis state. */ +export declare function hydrate( + root: Element, + componentModule: object, + expected: ExpectedHydration, +): Root; diff --git a/browser/hydrate.mjs b/browser/hydrate.mjs new file mode 100644 index 00000000..abd5f3de --- /dev/null +++ b/browser/hydrate.mjs @@ -0,0 +1,119 @@ +import { createElement, version as reactVersion } from 'react'; +import { version as reactDOMVersion } from 'react-dom'; +import { hydrateRoot } from 'react-dom/client'; + +const protocol = 1; +const initialized = new WeakSet(); +const fields = [ + 'protocol', 'entryId', 'export', 'buildId', 'rootId', + 'identifierPrefix', 'reactVersion', 'props', +]; + +function isFiniteData(value, depth = 0) { + if (depth > 128) return false; + if (typeof value === 'number') return Number.isFinite(value); + if (value && typeof value === 'object') { + return Object.values(value).every((item) => isFiniteData(item, depth + 1)); + } + return true; +} + +/** A transport or identity error detected before React changes the server HTML. */ +export class HydrationError extends Error { + constructor(code, message) { + super(message); + this.name = 'HydrationError'; + this.code = code; + } +} + +/** + * Hydrate one element using its adjacent captured state and a statically + * imported component module. Identity failures throw and preserve the HTML. + */ +export function hydrate(root, componentModule, expected) { + const fail = (code, message) => { + const error = new HydrationError(code, message); + try { + expected?.onError?.(error); + } finally { + throw error; + } + }; + if (!root || root.nodeType !== 1 || root.namespaceURI !== 'http://www.w3.org/1999/xhtml' + || ['html', 'head', 'body'].includes(root.localName) || !root.id) { + fail('root', 'Hydration requires an HTML element root with a nonempty ID.'); + } + if (initialized.has(root) || root.hasAttribute('data-phasis-hydrated')) { + fail('duplicate', 'This element has already been initialized by Phasis.'); + } + const matches = Array.from(root.ownerDocument.querySelectorAll('[id]')).filter((element) => element.id === root.id); + if (matches.length !== 1 || matches[0] !== root) { + fail('root', 'The hydration root ID must be unique within its document.'); + } + const tag = root.nextElementSibling; + if (!tag || tag.localName !== 'script' || tag.type !== 'application/json' + || tag.getAttribute('data-phasis-state') !== root.id) { + fail('state', 'The hydration root must be followed by its inert Phasis state tag.'); + } + let state; + try { + state = JSON.parse(tag.textContent); + } catch { + fail('state', 'The hydration state is not valid JSON.'); + } + if (!state || typeof state !== 'object' || Array.isArray(state) + || Object.keys(state).length !== fields.length + || fields.some((field) => !Object.hasOwn(state, field)) + || !state.props || typeof state.props !== 'object' || Array.isArray(state.props) + || fields.slice(1, 7).some((field) => typeof state[field] !== 'string') + || ['entryId', 'export', 'buildId', 'rootId', 'reactVersion'].some((field) => state[field] === '')) { + fail('state', 'The hydration state has an invalid shape.'); + } + if (!isFiniteData(state.props)) { + fail('state', 'The hydration props exceed the data depth or contain a non-finite number.'); + } + if (state.protocol !== protocol) { + fail('protocol', 'Unsupported Phasis hydration protocol.'); + } + if (!expected || typeof expected.entryId !== 'string' || !expected.entryId + || typeof expected.buildId !== 'string' || !expected.buildId + || (expected.export !== undefined && (typeof expected.export !== 'string' || !expected.export))) { + fail('identity', 'The browser entry must supply its expected entryId, buildId and export.'); + } + if (state.entryId !== expected.entryId || state.buildId !== expected.buildId + || state.export !== (expected.export ?? 'default') || state.rootId !== root.id) { + fail('identity', 'The server root and browser entry identities do not match.'); + } + if (state.reactVersion !== reactVersion || reactDOMVersion !== reactVersion) { + fail('react', 'The server, React and React DOM versions must match exactly.'); + } + if (!componentModule || !Object.hasOwn(componentModule, state.export)) { + fail('export', 'The statically imported module does not own the selected component export.'); + } + const component = componentModule[state.export]; + if (component === null || !['function', 'object', 'string', 'symbol'].includes(typeof component)) { + fail('export', 'The selected module export is not a React component.'); + } + const options = { identifierPrefix: state.identifierPrefix }; + for (const name of ['onRecoverableError', 'onCaughtError', 'onUncaughtError']) { + if (expected[name] !== undefined) { + if (typeof expected[name] !== 'function') { + fail('diagnostics', `${name} must be a function.`); + } + options[name] = expected[name]; + } + } + // React retains ownership of actual hydration diagnostics and recovery. + // A stale transport never reaches hydrateRoot or a createRoot fallback. + const element = createElement(component, state.props); + initialized.add(root); + root.setAttribute('data-phasis-hydrated', ''); + try { + return hydrateRoot(root, element, options); + } catch (error) { + initialized.delete(root); + root.removeAttribute('data-phasis-hydrated'); + throw error; + } +} diff --git a/compat.json b/compat.json index b9fd4630..24c6b184 100644 --- a/compat.json +++ b/compat.json @@ -1,10 +1,10 @@ { - "generatedAt": "2026-10-24T08:22:25+00:00", - "updatedAt": "2026-10-24T08:22:25+00:00", + "generatedAt": "2026-10-25T10:38:39+00:00", + "updatedAt": "2026-10-25T10:38:39+00:00", "complete": true, "git": { - "branch": "feat/express-hono-compatibility", - "commit": "b4624b71", + "branch": "feat/react-hydration", + "commit": "194f0635", "isDirty": true }, "environment": { @@ -19,8 +19,8 @@ }, "state": { "directory": ".compat-state", - "fingerprint": "6dae375cd029fff559e6f2252d22bcbacb3f0284", - "runIdentity": "0730fc2d37920605e99fb17b1b6f28fff808f5d951dec70f65acd883210dd4b3" + "fingerprint": "974314a028e0dd34ea2ae73d89fa4eb934451513", + "runIdentity": "278a834f4bf51053e32f434fea52e857994d14c14767b778073250df87ad87ec" }, "discovery": { "groupCount": 161, @@ -2717,45 +2717,45 @@ "blockedChunks": [], "slowChunks": [ { - "group": "staging/sm", + "group": "built-ins/decodeURIComponent", "kind": "normal", - "durationSec": 187.532, + "durationSec": 161.263, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-7-of-8.js" + "test262/test/built-ins/decodeURIComponent/S15.1.3.2_A2.5_T1.js" ] }, { - "group": "staging/sm", + "group": "built-ins/decodeURI", "kind": "normal", - "durationSec": 185.335, + "durationSec": 145.859, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-4-of-8.js" + "test262/test/built-ins/decodeURI/S15.1.3.1_A2.5_T1.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 183.376, + "durationSec": 100.749, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-8-of-8.js" + "test262/test/staging/sm/Date/dst-offset-caching-7-of-8.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 181.43, + "durationSec": 98.881, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-3-of-8.js" + "test262/test/staging/sm/Date/dst-offset-caching-4-of-8.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 176.894, + "durationSec": 96.5, "filesCount": 1, "sampleFiles": [ "test262/test/staging/sm/Date/dst-offset-caching-2-of-8.js" @@ -2764,70 +2764,74 @@ { "group": "staging/sm", "kind": "normal", - "durationSec": 167.859, + "durationSec": 94.585, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-5-of-8.js" + "test262/test/staging/sm/Date/dst-offset-caching-3-of-8.js" ] }, { - "group": "built-ins/decodeURIComponent", + "group": "staging/sm", "kind": "normal", - "durationSec": 164.085, + "durationSec": 91.265, "filesCount": 1, "sampleFiles": [ - "test262/test/built-ins/decodeURIComponent/S15.1.3.2_A2.5_T1.js" + "test262/test/staging/sm/Date/dst-offset-caching-8-of-8.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 156.742, + "durationSec": 83.485, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-6-of-8.js" + "test262/test/staging/sm/TypedArray/sort_large_countingsort.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 152.821, + "durationSec": 81.532, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/TypedArray/sort_large_countingsort.js" + "test262/test/staging/sm/Date/dst-offset-caching-1-of-8.js" ] }, { "group": "staging/sm", "kind": "normal", - "durationSec": 149.594, + "durationSec": 78.526, "filesCount": 1, "sampleFiles": [ - "test262/test/staging/sm/Date/dst-offset-caching-1-of-8.js" + "test262/test/staging/sm/Date/dst-offset-caching-5-of-8.js" ] }, { - "group": "built-ins/decodeURI", + "group": "staging/sm", "kind": "normal", - "durationSec": 144.082, + "durationSec": 77.426, "filesCount": 1, "sampleFiles": [ - "test262/test/built-ins/decodeURI/S15.1.3.1_A2.5_T1.js" + "test262/test/staging/sm/Date/dst-offset-caching-6-of-8.js" ] }, { - "group": "staging/sm", + "group": "built-ins/decodeURI", "kind": "normal", - "durationSec": 125.528, - "filesCount": 1, + "durationSec": 74.256, + "filesCount": 25, "sampleFiles": [ - "test262/test/staging/sm/TypedArray/element-setting-converts-using-ToNumber.js" + "test262/test/built-ins/decodeURI/S15.1.3.1_A1.11_T2.js", + "test262/test/built-ins/decodeURI/S15.1.3.1_A1.12_T1.js", + "...", + "test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T1.js", + "test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T2.js" ] }, { "group": "built-ins/decodeURIComponent", "kind": "normal", - "durationSec": 74.418, + "durationSec": 72.042, "filesCount": 25, "sampleFiles": [ "test262/test/built-ins/decodeURIComponent/S15.1.3.2_A1.11_T2.js", @@ -2838,35 +2842,44 @@ ] }, { - "group": "built-ins/decodeURI", + "group": "language/literals", "kind": "normal", - "durationSec": 73.594, + "durationSec": 71.99, "filesCount": 25, "sampleFiles": [ - "test262/test/built-ins/decodeURI/S15.1.3.1_A1.11_T2.js", - "test262/test/built-ins/decodeURI/S15.1.3.1_A1.12_T1.js", + "test262/test/language/literals/regexp/S7.8.5_A1.1_T1.js", + "test262/test/language/literals/regexp/S7.8.5_A1.1_T2.js", "...", - "test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T1.js", - "test262/test/built-ins/decodeURI/S15.1.3.1_A1.8_T2.js" + "test262/test/language/literals/regexp/S7.8.5_A2.2_T2.js", + "test262/test/language/literals/regexp/S7.8.5_A2.3_T1.js" ] }, { - "group": "annexB/built-ins/RegExp", + "group": "staging/sm", "kind": "normal", - "durationSec": 64.136, - "filesCount": 25, + "durationSec": 62.725, + "filesCount": 1, "sampleFiles": [ - "test262/test/annexB/built-ins/RegExp/RegExp-control-escape-russian-letter.js", - "test262/test/annexB/built-ins/RegExp/RegExp-decimal-escape-class-range.js", + "test262/test/staging/sm/TypedArray/element-setting-converts-using-ToNumber.js" + ] + }, + { + "group": "intl402", + "kind": "normal", + "durationSec": 62.168, + "filesCount": 22, + "sampleFiles": [ + "test262/test/intl402/constructors-string-and-single-element-array.js", + "test262/test/intl402/constructors-taint-Object-prototype-2.js", "...", - "test262/test/annexB/built-ins/RegExp/legacy-accessors/lastParen/this-cross-realm-constructor.js", - "test262/test/annexB/built-ins/RegExp/legacy-accessors/lastParen/this-not-regexp-constructor.js" + "test262/test/intl402/supportedLocalesOf-throws-if-element-not-string-or-object.js", + "test262/test/intl402/supportedLocalesOf-unicode-extensions-ignored.js" ] }, { "group": "intl402/DateTimeFormat", "kind": "normal", - "durationSec": 60.635, + "durationSec": 60.538, "filesCount": 25, "sampleFiles": [ "test262/test/intl402/DateTimeFormat/prototype/formatRangeToParts/temporal-plainmonthday-formatting-timezonename.js", @@ -2876,56 +2889,37 @@ "test262/test/intl402/DateTimeFormat/prototype/formatToParts/offset-timezone-correct.js" ] }, - { - "group": "intl402", - "kind": "normal", - "durationSec": 59.788, - "filesCount": 22, - "sampleFiles": [ - "test262/test/intl402/constructors-string-and-single-element-array.js", - "test262/test/intl402/constructors-taint-Object-prototype-2.js", - "...", - "test262/test/intl402/supportedLocalesOf-throws-if-element-not-string-or-object.js", - "test262/test/intl402/supportedLocalesOf-unicode-extensions-ignored.js" - ] - }, { "group": "built-ins/RegExp/property-escapes/generated", "kind": "normal", - "durationSec": 59.566, - "filesCount": 5, + "durationSec": 59.903, + "filesCount": 3, "sampleFiles": [ - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nandinagari.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_New_Tai_Lue.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Newa.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nko.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Nushu.js" + "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hanifi_Rohingya.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hanunoo.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Hatran.js" ] }, { "group": "built-ins/RegExp/property-escapes/generated", "kind": "normal", - "durationSec": 59.49, - "filesCount": 5, + "durationSec": 59.787, + "filesCount": 3, "sampleFiles": [ - "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Inscriptional_Parthian.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Javanese.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kaithi.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kannada.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Katakana.js" + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tibetan.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tifinagh.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Tirhuta.js" ] }, { "group": "built-ins/RegExp/property-escapes/generated", "kind": "normal", - "durationSec": 58.496, - "filesCount": 5, + "durationSec": 59.667, + "filesCount": 3, "sampleFiles": [ - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Todhri.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Tolong_Siki.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Toto.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Tulu_Tigalari.js", - "test262/test/built-ins/RegExp/property-escapes/generated/Script_-_Ugaritic.js" + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Kharoshthi.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Khitan_Small_Script.js", + "test262/test/built-ins/RegExp/property-escapes/generated/Script_Extensions_-_Khmer.js" ] } ] diff --git a/docs/modules.md b/docs/modules.md index 8d4e2950..4b1c9fe9 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -142,8 +142,12 @@ failures, blocked tests, source hashes and process timeouts. Framework versions and suite revisions are recorded there; passing a framework suite does not imply that the complete Node test suite or Hono's Bun/Deno/Fastly/workerd hosts pass. -Framework CI passes `--timeout-scale 10` to the Express and Hono runners so slow -PHP workloads can complete their original assertions. Reports retain the +Framework compatibility suites are **local-only and must never run in CI**, +including manual workflow dispatches or release gates. The workflow is removed +and disabled; releases do not require a passing framework suite. + +For local investigation, pass `--timeout-scale 10` to the Express and Hono +runners so slow PHP workloads can complete their original assertions. Reports retain the original and effective timeouts; scaled results do not establish upstream performance parity. The default scale is 1 for checking the original deadlines. This configuration does not affect test262 or engine execution budgets. diff --git a/docs/react.md b/docs/react.md index 826796fa..b53facda 100644 --- a/docs/react.md +++ b/docs/react.md @@ -32,21 +32,131 @@ export default function CommentsToggle({ count, excerpt }) { } ``` -PHP renders it and embeds the props, so the client renders the same tree: +For a matched server render and hydration payload, capture public data with +`renderForHydration()`. PHP needs no Node process: ```php -$props = ['count' => 3, 'excerpt' => $excerpt]; -echo '
' . $engine->render(__DIR__ . '/components/CommentsToggle.jsx', $props) . '
'; -echo ''; +use Phasis\Engine; +use Phasis\Hydration\HydrationOptions; + +$engine = new Engine(); +try { + $root = $engine->renderForHydration( + __DIR__ . '/components/CommentsToggle.jsx', + ['count' => 3, 'excerpt' => $excerpt], + options: new HydrationOptions( + rootId: 'comments', + entryId: 'comments', + buildId: $assetBuildId, + identifierPrefix: 'comments-', + ), + ); + echo $root->toHtml(); +} finally { + $engine->close(); +} ``` -On the client, standard React 19.3 tooling: +An external browser entry statically imports the same component module and the +shipped helper. Bundle this with the host's existing esbuild or other tooling: ```jsx -hydrateRoot(document.getElementById('comments'), ); +import * as Comments from './components/CommentsToggle.jsx'; +import { hydrate } from './vendor/phasis/phasis/browser/hydrate.mjs'; + +hydrate(document.getElementById('comments'), Comments, { + entryId: 'comments', + buildId: ASSET_BUILD_ID, // Supplied by this build, matching PHP's identity. + onError: reportTransportError, + onRecoverableError: reportReactError, + onCaughtError: reportReactError, + onUncaughtError: reportReactError, +}); ``` -The text markers React needs (`` between adjacent text) are in the output, and `useId()` produces the same `_R_..._` ids the client generates. The client must run React 19.3: other versions generate different ids and markup. +The helper is a runtime asset of the **Composer** package, at +`vendor/phasis/phasis/browser/hydrate.mjs`, with `hydrate.d.mts` declarations. +The example assumes an entry in the project root; adjust the relative path or +configure an explicit bundler alias for that installed file. There is no Phasis +npm package or implicit bare-specifier resolution. Its peer imports (`react`, +`react-dom`, `react-dom/client`) resolve through the consumer's bundler. Install +one compatible React/React DOM pair, currently **19.3.1**. The helper does not +bundle another copy. Node is development/build tooling, never the visitor's +execution backend. + +`RenderedRoot` exposes readonly `html`, `payload` and `options`. The payload is +captured **before** the component module executes. Phasis renders from those +captured values; later PHP mutation, JS mutation during render, or a component's +`toJSON` cannot alter the stored snapshot. The component sees ordinary props and +can provide them through its own React context. No automatic context or global +application singleton is created. PHP-to-JS callable/object interop remains +available through the existing `render()` and `renderSource()` APIs. + +### Data contract + +- Root props are a PHP map or a plain `stdClass`; empty PHP root props become + `{}`. A nonempty root list is rejected. Nested list arrays remain JSON arrays; + maps become JSON objects. Use `new stdClass()` for a nested empty object. +- Values are null, booleans, valid UTF-8 strings, finite doubles, safe PHP + integers, arrays and exact `stdClass` instances. Callable-looking strings and + arrays stay data. Arbitrary objects, serializers, closures, resources, cycles, + invalid UTF-8 and structures deeper than 128 levels are rejected before any + component module executes. Shared acyclic objects are allowed. +- PHP integers must fit `[-9007199254740991, 9007199254740991]`; use strings for + larger exact integers. PHP floats already have IEEE 754 double precision and + keep that precision, including signed zero. They may represent approximate + large numbers. Encoding and rendering temporarily use round-trip + `serialize_precision=-1` and restore the host setting, including on failure. +- Data-only JSON conversion preserves object/array distinctions, null and keys + without invoking PHP callbacks, JS `toJSON`, or JSON revivers. Normal React + `createElement` rules then apply to the root: reserved props and class + `defaultProps` behave as on the client. Use a named `data` prop for application + fields that collide with React's own props. + +### Transport, identity and diagnostics + +The versioned payload contains only protocol version, public entry/export/build +identity, root ID, React version, identifier prefix and captured props. The server +component path and executable source are never included. Public identities are +provided by the host; do not use a secret or server path as an identity. + +`toHtml()` emits a div with an escaped ID and an adjacent inert +`'; + } +} diff --git a/src/Runtime/Parts/ExpressionEvaluation.php b/src/Runtime/Parts/ExpressionEvaluation.php index 43472d60..509baa77 100644 --- a/src/Runtime/Parts/ExpressionEvaluation.php +++ b/src/Runtime/Parts/ExpressionEvaluation.php @@ -1321,7 +1321,7 @@ private function evalCallExpression(CallExpression $node, Environment $env, bool } $superHadNT = false; if ($currentThis instanceof JsObject) { - $superHadNT = !($currentThis->get('[[NewTarget]]') instanceof JsUndefined); + $superHadNT = !($currentThis->getNativeSlot('[[NewTarget]]') instanceof JsUndefined); if (!$superHadNT && $superNewTarget instanceof JsFunction) { $currentThis->defineOwnProperty( '[[NewTarget]]', @@ -3893,7 +3893,7 @@ private function callFunctionInner( // requires `TE === other.TypeError`. if ($fn->isClassConstructor()) { $calledAsNew = $thisValue instanceof JsObject - && !($thisValue->get('[[NewTarget]]') instanceof JsUndefined); + && !($thisValue->getNativeSlot('[[NewTarget]]') instanceof JsUndefined); if (!$calledAsNew) { $msg = "Class constructor {$fn->getName()} cannot be invoked without 'new'"; $fnRealm = $fn->realm; @@ -4301,7 +4301,7 @@ private function executeFunction( // Store the newObj so super() can pass it to the parent constructor. $fnEnv->defineVar('[[PendingThis]]', $thisValue); $ntDesc = $thisValue instanceof JsObject - ? $thisValue->getOwnPropertyDescriptor('[[NewTarget]]') + ? $thisValue->getNativeSlotDescriptor('[[NewTarget]]') : null; if ($ntDesc !== null) { $nt = $ntDesc->value; @@ -4323,7 +4323,7 @@ private function executeFunction( } if (!$vmCanSkipEnvBindings) { $ntDesc = ($thisValue instanceof JsObject && $fn->isConstructable()) - ? $thisValue->getOwnPropertyDescriptor('[[NewTarget]]') + ? $thisValue->getNativeSlotDescriptor('[[NewTarget]]') : null; if ($ntDesc !== null) { $nt = $ntDesc->value; @@ -5895,7 +5895,7 @@ public function vmLookupPrimitiveMember(JsValue $obj, string $name): JsValue } if ($obj instanceof JsObject) { // Symbol-wrapper → fall through to the full slow path. - $primSlot = $obj->getOwnPropertyDescriptor('[[PrimitiveValue]]'); + $primSlot = $obj->getNativeSlotDescriptor('[[PrimitiveValue]]'); if ($primSlot !== null && $primSlot->value instanceof JsSymbol) { $val = $this->lookupSymbolPrototypeProperty($primSlot->value, $name, false, null); if ($val !== null) { @@ -6973,7 +6973,7 @@ private function evalMemberExpression(MemberExpression $node, Environment $env): !$node->computed && $node->property instanceof Identifier && $obj instanceof JsObject - && $obj->getOwnPropertyDescriptor('[[PrimitiveValue]]') === null + && $obj->getNativeSlotDescriptor('[[PrimitiveValue]]') === null ) { return $obj->get($node->property->name); } @@ -7120,7 +7120,7 @@ private function evalMemberExpression(MemberExpression $node, Environment $env): if ($obj instanceof JsObject) { // Symbol wrapper objects created via Object(sym): look up on Symbol.prototype chain // so that description, toString, etc. are accessible with correct this-binding. - $ownDesc = $obj->getOwnPropertyDescriptor('[[PrimitiveValue]]'); + $ownDesc = $obj->getNativeSlotDescriptor('[[PrimitiveValue]]'); if ($ownDesc !== null && $ownDesc->value instanceof JsSymbol) { $val = $this->lookupSymbolPrototypeProperty( $ownDesc->value, diff --git a/src/Runtime/Parts/StatementExecution.php b/src/Runtime/Parts/StatementExecution.php index 56804128..6d8b8cc2 100644 --- a/src/Runtime/Parts/StatementExecution.php +++ b/src/Runtime/Parts/StatementExecution.php @@ -764,7 +764,7 @@ function (JsValue $thisVal, array $args) use ($ref) { // active function's new.target, not the super itself. $newTarget = $ref->fn; if ($thisVal instanceof JsObject) { - $nt = $thisVal->get('[[NewTarget]]'); + $nt = $thisVal->getNativeSlot('[[NewTarget]]'); if ($nt instanceof JsFunction || $nt instanceof \Phasis\Value\JsProxy) { $newTarget = $nt; } diff --git a/src/Value/JsDataObject.php b/src/Value/JsDataObject.php new file mode 100644 index 00000000..24e7d2c7 --- /dev/null +++ b/src/Value/JsDataObject.php @@ -0,0 +1,14 @@ +isClassConstructor) { $calledAsNew = $thisValue instanceof JsObject - && !($thisValue->get('[[NewTarget]]') instanceof JsUndefined); + && !($thisValue->getNativeSlot('[[NewTarget]]') instanceof JsUndefined); if (!$calledAsNew) { $msg = "Class constructor {$this->name} cannot be invoked without 'new'"; $fnRealm = $this->realm; diff --git a/src/Value/JsObject.php b/src/Value/JsObject.php index 9da92885..b53ecd6a 100644 --- a/src/Value/JsObject.php +++ b/src/Value/JsObject.php @@ -121,6 +121,42 @@ public function getInternalProperty(string $name): mixed return $this->internalSlots[$name] ?? null; } + /** + * Read legacy native metadata without interpreting an ordinary data key as a slot. + * Native proxy/accessor reads can invoke user code and mutate the object. + * + * @phpstan-impure + */ + public function getNativeSlot(string $name): JsValue + { + return $this instanceof JsDataObject ? JsUndefined::instance() : $this->get($name); + } + + /** + * Check legacy native metadata while keeping public data properties unbranded. + * Proxy has traps can mutate state between otherwise identical checks. + * + * @phpstan-impure + */ + public function hasNativeSlot(string $name, bool $own = false): bool + { + if ($this instanceof JsDataObject) { + return false; + } + return $own ? $this->hasOwnProperty($name) : $this->has($name); + } + + /** + * Inspect legacy native metadata without exposing public data as a native slot. + * Exotic descriptors can invoke user code or change between calls. + * + * @phpstan-impure + */ + public function getNativeSlotDescriptor(string $name): ?PropertyDescriptor + { + return $this instanceof JsDataObject ? null : $this->getOwnPropertyDescriptor($name); + } + public static function setGlobalPrototype(JsObject $proto): void { self::$globalPrototype = $proto; @@ -975,7 +1011,7 @@ public function ownKeys(): array { return array_values(array_filter( $this->properties->keys(), - fn(string $k) => !self::isInternalSlot($k), + fn(string $k) => !static::isInternalSlot($k), )); } @@ -993,7 +1029,7 @@ public function getOwnPropertyNames(): array $integerIndices = []; $nonIndexStrings = []; foreach ($allKeys as $key) { - if (self::isInternalSlot($key)) { + if (static::isInternalSlot($key)) { continue; } if (self::isArrayIndex($key)) { @@ -1088,7 +1124,7 @@ public function ordinaryOwnPropertyKeys(): array $integerIndices = []; $nonIndexStrings = []; foreach ($stringKeys as $key) { - if (self::isInternalSlot($key)) { + if (static::isInternalSlot($key)) { continue; } if (self::isArrayIndex($key)) { @@ -1169,7 +1205,7 @@ public function getOwnEnumerableKeys(): array $integerIndices = []; $nonIndexStrings = []; foreach ($enumKeys as $key) { - if (self::isInternalSlot($key)) { + if (static::isInternalSlot($key)) { continue; } if (self::isArrayIndex($key)) { diff --git a/tests/Frameworks/README.md b/tests/Frameworks/README.md new file mode 100644 index 00000000..a200438a --- /dev/null +++ b/tests/Frameworks/README.md @@ -0,0 +1,19 @@ +# Local-only framework compatibility + +**Never run these suites in CI.** This includes Express, Hono and the associated +HTTP/2 compatibility checks, through automatic triggers, manual dispatches, +release gates or workflows from old revisions. + +The owner made this decision on 2026-10-25. The Framework Compatibility workflow +is removed from this repository and disabled in GitHub Actions. Do not restore +it, dispatch it, or add these runners to another workflow. A cancelled or absent +framework check does not block merging or releasing. + +Keep the runners for deliberate local investigation. Preserve original +assertions, source identities, upstream skips, original/effective deadlines and +owned-process cleanup. A local timeout scale is a functional compatibility +check, not a performance-parity claim. The main README and Hono guide document +the local commands. + +Engine unit tests, oracle regressions, JSX/hydration, WPT, popular packages, +test262 and dependency/quality checks retain their existing CI roles. diff --git a/tests/Frameworks/express/run.py b/tests/Frameworks/express/run.py index a1b4e9c4..be3cda71 100644 --- a/tests/Frameworks/express/run.py +++ b/tests/Frameworks/express/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run the complete pinned Express suite with bounded, isolated worker groups.""" +"""Local-only Express compatibility. Never run in CI; see ../README.md.""" import argparse import concurrent.futures diff --git a/tests/Frameworks/hono/README.md b/tests/Frameworks/hono/README.md index 40ff46ac..1814fce6 100644 --- a/tests/Frameworks/hono/README.md +++ b/tests/Frameworks/hono/README.md @@ -1,7 +1,9 @@ # Experimental Hono compatibility -Hono support is experimental. CI checks functional compatibility with an explicit -timeout scale of 10; meeting the original performance deadlines is deferred. +Hono support is experimental. These suites are **local-only and must never run +in CI**, including manual dispatches or release gates. See the +[framework policy](../README.md). Meeting the original performance deadlines +is deferred. The ignored source tree in `upstream/hono-4.13.13` is the unchanged Hono v4.13.13 archive at commit `08a023cbfde55b434fb0fb30fae35d42e4bf20aa`. The repository, @@ -35,7 +37,7 @@ runs both and compares every discovered case's identity and outcome. Each file runs in a separate process, sequentially, with a 1,800 second outer wall limit (`--timeout` overrides it). This lets large files finish hundreds of cases while retaining the original per-test and per-hook deadlines by default. -Pass `--timeout-scale 10` to match CI, or set +For a local scaled run, pass `--timeout-scale 10`, or set `PHASIS_FRAMEWORK_TIMEOUT_SCALE=10`; an explicit CLI value takes precedence. The scale applies equally to the Node harness and Phasis, and reports record the scale plus each case's original and effective timeout. The outer worker diff --git a/tests/Frameworks/hono/run.py b/tests/Frameworks/hono/run.py index 6671e5c2..75022d20 100644 --- a/tests/Frameworks/hono/run.py +++ b/tests/Frameworks/hono/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run unchanged transpiled Hono tests in bounded Node/Phasis subprocesses.""" +"""Local-only Hono compatibility. Never run in CI; see ../README.md.""" import argparse import json import hashlib diff --git a/tests/Frameworks/http2/interop.mjs b/tests/Frameworks/http2/interop.mjs index aa3d2663..30fad4a2 100644 --- a/tests/Frameworks/http2/interop.mjs +++ b/tests/Frameworks/http2/interop.mjs @@ -67,3 +67,4 @@ try { for (const child of running) child.kill('SIGKILL'); rmSync(materialDirectory, { recursive: true, force: true }); } +// Local-only framework interoperability. Never run in CI; see ../README.md. diff --git a/tests/Jsx/JsxHydrationTest.php b/tests/Jsx/JsxHydrationTest.php new file mode 100644 index 00000000..97483a0d --- /dev/null +++ b/tests/Jsx/JsxHydrationTest.php @@ -0,0 +1,309 @@ +engine = new Engine(); + } + + protected function tearDown(): void + { + $this->engine->close(); + } + + private function options(string $rootId = 'root', string $prefix = 'test-'): HydrationOptions + { + return new HydrationOptions(rootId: $rootId, entryId: 'test-entry', buildId: 'build-123', identifierPrefix: $prefix); + } + + public function testCapturesBeforeModuleEvaluationAndNeverReserializesRenderedProps(): void + { + $data = (object) ['value' => 10]; + $this->engine->setGlobal('changeInput', static function () use ($data): void { + $data->value = 99; + }); + $result = $this->engine->renderForHydration(__DIR__ . '/hydration/capture.jsx', ['data' => $data], options: $this->options()); + $this->assertSame(99, $data->value); + $this->assertSame('

11

', $result->html); + $state = json_decode($result->payload, true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(['value' => 10], $state['props']['data']); + $this->assertSame(1, $state['protocol']); + $this->assertSame('19.3.1', $state['reactVersion']); + $this->assertStringNotContainsString(__DIR__, $result->payload); + $this->assertStringNotContainsString('toJSON', $result->payload); + } + + public function testDataShapesAndCallableLookingArraysRemainData(): void + { + $data = (object) [ + 'emptyObject' => new \stdClass(), + 'emptyArray' => [], + 'null' => null, + 'callable' => ['DateTime', 'createFromFormat'], + '__proto__' => (object) ['polluted' => true], + 'constructor' => ['prototype' => ['safe' => true]], + 'toJSON' => 'ordinary data', + 'numericMap' => [2 => 'two', 0 => 'zero'], + ]; + $result = $this->engine->renderForHydration( + __DIR__ . '/hydration/Root.jsx', + ['data' => $data], + export: 'Shapes', + options: $this->options(), + ); + $state = json_decode($result->payload, flags: JSON_THROW_ON_ERROR); + $this->assertInstanceOf(\stdClass::class, $state->props->data->emptyObject); + $this->assertSame([], $state->props->data->emptyArray); + $this->assertSame(['DateTime', 'createFromFormat'], $state->props->data->callable); + $this->assertTrue($state->props->data->__proto__->polluted); + $this->assertStringContainsString('"toJSON":"ordinary data"', $result->html); + $this->assertFalse($this->engine->eval('Object.hasOwn(Object.prototype, "polluted")')); + } + + public function testEmptyRootPropsAreObjectsAndClassDefaultsAndReservedPropsMatchCreateElement(): void + { + $result = $this->engine->renderForHydration( + __DIR__ . '/hydration/Root.jsx', + ['key' => 'root-key', '__self' => 'hidden', '__source' => 'hidden', 'ref' => null], + export: 'Defaults', + options: $this->options(), + ); + $this->assertSame('

fallback:label

', $result->html); + $empty = $this->engine->renderForHydration(__DIR__ . '/hydration/Root.jsx', export: 'Defaults', options: $this->options()); + $this->assertInstanceOf(\stdClass::class, json_decode($empty->payload)->props); + $this->assertSame('

fallback:label

', $empty->html); + } + + public function testFiniteDoublesSignedZeroAndSafeIntegersRoundTripDespiteHostPrecision(): void + { + $previous = ini_set('serialize_precision', '3'); + try { + $result = $this->engine->renderForHydration( + __DIR__ . '/hydration/Root.jsx', + ['data' => ['zero' => -0.0, 'decimal' => 1.2345678901234567, 'max' => 9007199254740991, 'large' => 1.0e100]], + export: 'Shapes', + options: $this->options(), + ); + $this->assertSame('3', ini_get('serialize_precision')); + $state = json_decode($result->payload); + $this->assertSame(-INF, fdiv(1.0, $state->props->data->zero)); + $this->assertSame(1.2345678901234567, $state->props->data->decimal); + $this->assertSame(9007199254740991, $state->props->data->max); + $this->assertStringContainsString('negative-zero', $result->html); + $this->assertStringContainsString('1.2345678901234567', $result->html); + } finally { + ini_set('serialize_precision', $previous); + } + } + + public function testScriptSafetyAndAttributeEscapingAreSeparate(): void + { + $text = '' . "\u{2028}\u{2029}"; + $id = 'root"<&\''; + $result = $this->engine->renderForHydration( + __DIR__ . '/hydration/Root.jsx', + ['data' => ['text' => $text]], + export: 'Shapes', + options: $this->options($id), + ); + $this->assertStringNotContainsString('<', $result->payload); + $this->assertStringNotContainsString('"', $result->payload); + $this->assertSame($text, json_decode($result->payload)->props->data->text); + $this->assertStringContainsString('id="root"<&'"', $result->toHtml()); + $this->assertSame(1, substr_count($result->toHtml(), '' . "\u{2028}\u{2029}", + ]]], +]; +$html = ''; +foreach ($cases as $case) { + $engine = new Engine(); + try { + $rendered = $engine->renderForHydration( + $consumer . '/components/Root.jsx', + $case['props'], + export: $case['export'], + options: new HydrationOptions( + rootId: $case['id'], + entryId: 'contract', + buildId: 'build-123', + identifierPrefix: $case['id'] . '-', + ), + ); + $html .= $rendered->toHtml(); + } finally { + $engine->close(); + } +} +echo $html; diff --git a/tests/Jsx/hydration/Root.jsx b/tests/Jsx/hydration/Root.jsx new file mode 100644 index 00000000..87888169 --- /dev/null +++ b/tests/Jsx/hydration/Root.jsx @@ -0,0 +1,58 @@ +import React, { Component, createContext, useContext, useId, useState } from 'react'; +import { format } from './format.js'; + +const Data = createContext(null); + +function Counter({ count, increment }) { + const data = useContext(Data); + const id = useId(); + return ; +} + +function Value({ count }) { + const data = useContext(Data); + return {data.value}:{count}; +} + +export default function Root({ data }) { + const [count, setCount] = useState(0); + // Deliberate impurity proves that the payload is the pre-render capture. + if (data.mutate) data.value += 1; + return +
+ setCount(count + 1)} /> + +
+
; +} + +export class Defaults extends Component { + static defaultProps = { label: 'fallback' }; + render() { + return

{this.props.label}:{Object.keys(this.props).sort().join(',')}

; + } +} + +export function Shapes({ data }) { + return
{JSON.stringify(data)}:{Object.is(data.zero, -0) ? 'negative-zero' : 'positive-zero'}:{data.decimal}
; +} + +export function Keys(props) { + let brand; + try { + Number.prototype.valueOf.call(props.data); + brand = 'number'; + } catch (error) { + brand = error.name; + } + return
{Object.keys(props).sort().join(',')}:{Reflect.ownKeys(props.data).join(',')}:{JSON.stringify(props.data)}
+        :{String(props.data)}:{Object.prototype.toString.call(props.data)}:{brand}
; +} + +export function Document() { + return DocumentContent; +} + +export function Broken() { + throw new Error('render failed deliberately'); +} diff --git a/tests/Jsx/hydration/capture.jsx b/tests/Jsx/hydration/capture.jsx new file mode 100644 index 00000000..9eb65d3b --- /dev/null +++ b/tests/Jsx/hydration/capture.jsx @@ -0,0 +1,8 @@ +globalThis.moduleLoaded = true; +globalThis.changeInput?.(); + +export default function Capture({ data }) { + data.value += 1; + data.toJSON = () => { throw new Error('must not reserialize rendered props'); }; + return

{data.value}

; +} diff --git a/tests/Jsx/hydration/format.js b/tests/Jsx/hydration/format.js new file mode 100644 index 00000000..9141334f --- /dev/null +++ b/tests/Jsx/hydration/format.js @@ -0,0 +1,3 @@ +export function format(value) { + return `Count: ${value}`; +} diff --git a/tests/Jsx/oracle/hydration-contract.mjs b/tests/Jsx/oracle/hydration-contract.mjs new file mode 100644 index 00000000..e5170269 --- /dev/null +++ b/tests/Jsx/oracle/hydration-contract.mjs @@ -0,0 +1,214 @@ +// Qualify the actual Composer archive in an isolated downstream consumer. +// Node/esbuild are test/build tools; PHP alone executes the server render. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; +import { JSDOM } from 'jsdom'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, '../../..'); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'phasis-hydration-')); +const sourceMode = process.argv.includes('--source'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const until = async (test) => { + const deadline = Date.now() + 5000; + while (!test()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for React.'); + await sleep(10); + } +}; +let checks = 0; +try { + if (sourceMode) { + fs.mkdirSync(path.join(temp, 'vendor/phasis'), { recursive: true }); + fs.symlinkSync(repo, path.join(temp, 'vendor/phasis/phasis'), 'dir'); + fs.writeFileSync(path.join(temp, 'vendor/autoload.php'), ` /react\/cjs\/react\.(production|development)\.js$/.test(input)); + assert.equal(reactInputs.length, 1, 'The helper and components must share one React copy.'); + const code = bundle.outputFiles[0].text; + const dom = new JSDOM(`${html}`, { + runScripts: 'outside-only', pretendToBeVisual: true, url: 'https://consumer.test/', + }); + const { window } = dom; + const logs = []; + window.console.error = (...args) => logs.push(args.map(String).join(' ')); + window.console.warn = (...args) => logs.push(args.map(String).join(' ')); + const roots = []; + try { + window.eval(code); + const { hydrate, HydrationError, components } = window.contract; + const errors = []; + const options = (name = 'default') => ({ + entryId: 'contract', buildId: 'build-123', export: name, + onRecoverableError: (error) => errors.push(error), + onUncaughtError: (error) => errors.push(error), + onCaughtError: (error) => errors.push(error), + }); + const before = Array.from(window.document.querySelectorAll('div[id]')).flatMap((root) => { + const nodes = []; + const walker = window.document.createTreeWalker(root, 1 | 4 | 128); + for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node); + return nodes; + }); + for (const [id, name] of [['alpha', 'default'], ['beta', 'default'], ['named', 'Defaults'], ['empty', 'Defaults'], + ['shapes', 'Shapes'], ['keys', 'Keys']]) { + roots.push(hydrate(window.document.getElementById(id), components, options(name))); + } + await sleep(100); + assert.deepEqual(errors, []); + assert.deepEqual(logs, [], 'Hydration must produce no React warnings.'); + assert.ok(before.every((node) => node.isConnected), 'All server elements, text and comments must be reused.'); + assert.equal(window.document.querySelector('#alpha output').textContent, '11:0'); + const alphaState = JSON.parse(window.document.getElementById('alpha').nextElementSibling.textContent); + assert.equal(alphaState.props.data.value, 10, 'Payload must precede render mutation.'); + assert.equal(window.document.querySelector('#named p').textContent, 'fallback:label'); + assert.equal(window.document.querySelector('#empty p').textContent, 'fallback:label'); + const a = window.document.querySelector('#alpha button'); + const b = window.document.querySelector('#beta button'); + assert.ok(a.id.startsWith('_alpha-')); + assert.ok(b.id.startsWith('_beta-')); + assert.notEqual(a.id, b.id); + assert.equal(window.exploit, undefined); + assert.equal(window.document.querySelectorAll('script').length, 6, 'Captured text must not escape an inert state tag.'); + assert.equal(window.Object.prototype.polluted, undefined); + a.click(); + await until(() => window.document.querySelector('#alpha output').textContent.endsWith(':1')); + assert.equal(window.document.querySelector('#beta output').textContent, '20:0'); + checks += 15; + + // Mismatched metadata fails synchronously, preserving the exact nodes. + const base = window.document.getElementById('beta'); + const baseState = JSON.parse(base.nextElementSibling.textContent); + const make = (change = () => {}) => { + const root = window.document.createElement('div'); + const id = `failure-${checks}`; + root.id = id; + root.innerHTML = base.innerHTML; + const tag = window.document.createElement('script'); + tag.type = 'application/json'; + tag.setAttribute('data-phasis-state', id); + const state = { ...baseState, rootId: id }; + change(state); + tag.textContent = JSON.stringify(state); + window.document.body.append(root, tag); + return { root, tag }; + }; + const cases = [ + ['protocol', (state) => { state.protocol = 2; }], + ['entry', (state) => { state.entryId = 'other'; }], + ['build', (state) => { state.buildId = 'old'; }], + ['export', (state) => { state.export = 'Defaults'; }], + ['React', (state) => { state.reactVersion = '0.0.0'; }], + ['root', (state) => { state.rootId = 'other'; }], + ['extra executable URL', (state) => { state.url = 'https://untrusted.test/run.js'; }], + ['array props', (state) => { state.props = []; }], + ['null props', (state) => { state.props = null; }], + ['missing key', (state) => { delete state.identifierPrefix; }], + ]; + for (const [name, change] of cases) { + const { root, tag } = make(change); + const node = root.firstChild; + const original = root.innerHTML; + const reports = []; + assert.throws(() => hydrate(root, components, { ...options(), onError: (error) => reports.push(error) }), HydrationError, name); + assert.equal(reports.length, 1); + assert.equal(root.innerHTML, original); + assert.equal(root.firstChild, node); + root.remove(); tag.remove(); + checks++; + } + for (const text of ['{', '{"protocol":1}', base.nextElementSibling.textContent.replace('20', '1e400')]) { + const { root, tag } = make(); + tag.textContent = text; + assert.throws(() => hydrate(root, components, options()), HydrationError); + root.remove(); tag.remove(); + checks++; + } + const inherited = make(); + assert.throws(() => hydrate(inherited.root, Object.create({ default: components.default }), options()), HydrationError); + inherited.root.remove(); inherited.tag.remove(); + checks++; + assert.throws(() => hydrate(base, components, options()), HydrationError, 'duplicate initialization'); + window.eval(code); + assert.throws(() => window.contract.hydrate(base, components, options()), /already been initialized/, 'duplicate helper bundle'); + checks += 2; + + // Real markup mismatches go to the caller's React diagnostics. + const mismatch = make(); + mismatch.root.querySelector('output').textContent = 'stale'; + roots.push(hydrate(mismatch.root, components, options())); + await until(() => errors.length > 0); + assert.ok(errors.some((error) => /hydrat|match|#418/i.test(String(error)))); + checks++; + } finally { + for (const root of roots) root.unmount(); + window.close(); + } + console.log(`hydration contract: ${mode} passed`); + } + console.log(`hydration contract: ${checks} checks passed (${sourceMode ? 'source checkout' : 'Composer archive consumer'})`); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/tests/Jsx/oracle/package-lock.json b/tests/Jsx/oracle/package-lock.json index 890a6c3d..4d517a6a 100644 --- a/tests/Jsx/oracle/package-lock.json +++ b/tests/Jsx/oracle/package-lock.json @@ -28,6 +28,11 @@ "react-markdown": "10.1.0", "tailwind-merge": "3.7.0", "use-sync-external-store": "1.7.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "typescript": "7.0.2" } }, "node_modules/@asamuzakjp/css-color": { @@ -631,17 +636,366 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", @@ -771,8 +1125,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", @@ -2198,6 +2551,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/tests/Jsx/oracle/package.json b/tests/Jsx/oracle/package.json index 190ed39b..558603c3 100644 --- a/tests/Jsx/oracle/package.json +++ b/tests/Jsx/oracle/package.json @@ -17,6 +17,7 @@ "lucide-react": "1.56.0", "preact": "11.0.1", "preact-render-to-string": "6.8.1", + "prettier": "3.9.10", "prop-types": "15.8.1", "react": "19.3.1", "react-dom": "19.3.1", @@ -24,7 +25,11 @@ "react-is": "19.3.1", "react-markdown": "10.1.0", "tailwind-merge": "3.7.0", - "use-sync-external-store": "1.7.0", - "prettier": "3.9.10" + "use-sync-external-store": "1.7.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "typescript": "7.0.2" } }