Repository navigation
test(ssr): add update contracts and artifact regression baseline #5048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| # SSR cache update: R0 contract and regression baseline | ||
|
|
||
| Status: implementation contract for the RFC, not an implemented update API. | ||
| [RFC and roadmap](https://bytedance.larkoffice.com/docx/I8VZdLKxPoI85NxNTkrcX2Zmnuf). | ||
|
|
||
| ## Run the baseline | ||
|
|
||
| From the repository root, with Node 24 and the lockfile's pnpm version: | ||
|
|
||
| ```sh | ||
| pnpm exec turbo run build --filter=@module-federation/runtime-tools | ||
| node --test tools/ssr-cache/baseline.test.cjs | ||
| ``` | ||
|
|
||
| The default uses the installed `@rspack/core` (currently the lockfile's canary). | ||
| To validate a local Rspack build, set `SSR_CACHE_RSPACK_ENTRY` to its absolute | ||
| `packages/rspack/dist/index.js` path. The test reports the resolved path and | ||
| version; a local build's version alone does not identify its commit. | ||
|
|
||
| Set `SSR_CACHE_MODERN_ENTRY` to a built Modern | ||
| `packages/server/core/dist/cjs/adapters/node/index.js` to also run the resource | ||
| publication HTTP test. Without it, that test is explicitly **skipped**, not passed. | ||
| No dependency or lockfile is rewritten. Each compiler case runs in its own Node | ||
| process and owns a temporary directory, removed on completion. Application | ||
| rebuilds within each case remain in the same process. | ||
|
|
||
| Known failures execute as Node test TODOs. They assert the desired behavior, not | ||
| that stale behavior is correct. An unexpected pass fails the parent test so the | ||
| TODO must be removed. To turn all known failures into blocking failures: | ||
|
|
||
| ```sh | ||
| SSR_CACHE_STRICT=1 node --test tools/ssr-cache/baseline.test.cjs | ||
| ``` | ||
|
|
||
| A successful baseline run with TODOs is **not** production acceptance. When fixing | ||
| a defect, remove its TODO and keep its desired-behavior assertion. These tests are | ||
| an explicit development command; this change does not alter the CI workflows. | ||
|
|
||
| | Case | What is exercised | Current expectation | | ||
| | -------------------- | --------------------------------------------------- | ------------------------------------------------------------------- | | ||
| | plain | Static multi-level consumers across emitted chunks | Reacquired page remains stale: TODO | | ||
| | concat | Same graph with module concatenation | Page updates; saved function remains old | | ||
| | parents | Diagnostic JS plugin supplies missing parent edges | Page updates; unrelated module executes once | | ||
| | shared | Real provider singleton consumed by host | Host invalidation and shared strict identity: TODO | | ||
| | all non-shared cases | Drop application CJS cache, then update again | Dynamic reference refreshes; old adapter still called: TODO | | ||
| | Modern (opt-in) | Real production resource plugin and one HTTP server | Recreate resource state to publish new manifest; PID/port unchanged | | ||
|
|
||
| The `parents` plugin and manual page invalidation/hook rebinding in the fixture | ||
| are diagnostic interventions, not the proposed production implementation. The | ||
| fixture temporarily uses remove + register to exercise existing code; this does | ||
| not specify atomic update semantics. No test here proves complete React streaming, | ||
| HTTP remote transport, hydration compatibility, native ESM unloading, cyclic or | ||
| multi-entry graph completeness, shared lazy dependency retention, or stable heap | ||
| usage. Those remain R1–R6 work. | ||
|
|
||
| ## Cross-layer ownership | ||
|
|
||
| | Owner | Required responsibilities | Must not assume | | ||
| | ---------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | ||
| | Modern coordinator | Admission/drain, affected entry mapping, runtime lifecycle, publication, recovery, applied revision | Clearing MF caches replaces renderer/loader/lazy references | | ||
| | MF runtime | Explicit remote configuration update and owned cache cleanup; report errors | It can drain arbitrary Node callers or undo business side effects | | ||
| | Bundler adapter | Attach current bundler, invalidate owned execution state, generation checks, detach wrappers/listeners | A same-name MF plugin installation replaces an old closure | | ||
| | Rspack | Generic module/parent/chunk metadata and MF cache primitives | Chunk identity is equivalent to a Modern route | | ||
| | Modern JS compilation plugin | Map compilation metadata to application entries and indicate completeness | Dynamically computed remote identifiers can always be resolved statically | | ||
|
|
||
| ## MF instance and adapter lifetime (R0 decision) | ||
|
|
||
| For a Modern-owned logical host, keep the MF instance across application rebuilds. | ||
| A persistent control plane owns its authoritative remote configuration and its | ||
| consumed shared records. An application generation owns its bundler adapters and | ||
| rendering resources. Do not create a fresh same-name instance on every rebuild: | ||
| that alone neither releases global records nor preserves shared identity. | ||
|
|
||
| One application can own multiple hosts/bundlers, including separate loader | ||
| bundles. Track each explicitly; a single global `currentBundler` pointer is not a | ||
| valid contract. Do not detach adapters belonging to another still-live owner. | ||
|
|
||
| An adapter attachment returns an idempotent disposer. Its identity is the actual | ||
| bundler runtime plus owner/generation, not the plugin name. Register callbacks and | ||
| wrappers once per attachment. Detach must remove its listeners, unregister its | ||
| routing, release captured bundler references, and restore only wrappers it still | ||
| owns (never overwrite a later third-party wrapper). Repeated attach of the same | ||
| live binding must not stack wrappers. A disposed generation cannot write results | ||
| into the current generation. Existing already-returned exports are not rewritten. | ||
|
|
||
| During rebuild, drain first; use old adapters while their caches still need | ||
| cleanup; detach before discarding the old runtime; initialize new bundles from the | ||
| control plane's latest configuration; attach and validate new bindings before | ||
| publication. Generated startup configuration must not silently overwrite an | ||
| accepted dynamic update. A failed rebuild may leave no serving generation; retain | ||
| the control plane needed to retry. This ordering must be validated in R1/R3. | ||
|
|
||
| Consumed shared exports must retain strict object identity, including dependencies | ||
| needed for later lazy work. The implementation may retain a provider runtime when | ||
| safe selective invalidation is unavailable; host consumer invalidation must still | ||
| occur. Do not promise full provider GC or mutate an in-use shared singleton into a | ||
| new version. Shared preservation is a correctness requirement, not just a loaded | ||
| flag. Changes to an in-use shared dependency outside this contract must be reported | ||
| as unsupported, not silently treated as an ordinary remote update. | ||
|
|
||
| ## Update operation and revision contract (R0 decision) | ||
|
|
||
| Names below describe internal semantics, not final public TypeScript API names. | ||
|
|
||
| - `operationId` identifies a request for update. A retry can be related to its | ||
| original operation; it is not permission to apply the same mutation twice. | ||
| - A normalized target configuration is retained separately from the last serving | ||
| configuration. `appliedRevision` advances only after Modern has validated and | ||
| published a serving runtime. Clearing MF caches alone cannot advance it. | ||
| - Revision ordering is local to an application/worker. Do not compare opaque | ||
| remote version strings lexically. External sources with ordering use an explicit | ||
| source revision; otherwise accepted operations are serialized in arrival order. | ||
| - Resolve every caller's Promise with its own outcome. Do not silently merge | ||
| distinct updates or report all callers successful because the last update worked. | ||
| - Validate the whole requested remote set before destructive changes. Multi-remote | ||
| failure after mutation is not transactional rollback; recovery requires a rebuild | ||
| from a complete retained target configuration. | ||
| - `registerRemotes` registers previously unknown remotes. Existing registration | ||
| updates use the explicit asynchronous update API; `force` is deprecated with a | ||
| documented migration. Adapter attach is distinct from user registration. | ||
|
|
||
| An update result exposes operation ID, requested revision, applied revision, | ||
| selected mode/scope and fallback reason. A failure additionally exposes phase, | ||
| original cause, whether destructive mutation began, whether the application is | ||
| still serving, and whether retry/rebuild is possible. The phase progression is: | ||
|
|
||
| ```text | ||
| validate → analyze → close admission → drain → mutate → rebuild → validate runtime → publish → reopen | ||
| ``` | ||
|
|
||
| Validation/analysis failure leaves the old runtime available. Drain timeout does | ||
| not prove old work stopped: abandon before mutation and reopen the old generation. | ||
| After destructive mutation, failure must not reopen partially modified state. | ||
| Retry rebuild under the closed gate, with bounded waiting and explicit failure. | ||
| No universal rollback of arbitrary JavaScript side effects is promised. | ||
|
|
||
| ## Impact analysis and request admission (R0 decision) | ||
|
|
||
| Start with generic module/dependency/chunk metadata, then map the affected closure | ||
| to Modern resources and entry ownership. Static **consumption** must be traceable; | ||
| static registration alone is insufficient. Missing graph edges, dynamic/mixed | ||
| consumption, or inseparable shared application context require widening scope. | ||
| If whole-application rebuild is itself unsupported, fail explicitly. | ||
|
|
||
| A request acquires a lease on an eligible serving generation before running any | ||
| application loader/render/action. Checking admission, selecting the generation | ||
| and registering the lease must be coordinated. The closed gate's Promise is only | ||
| a notification: waking requests recheck admission, including after another update | ||
| has started. Do not capture an old handler before waiting. | ||
|
|
||
| Old leases finish without awaiting the update that drains them. A request-context | ||
| attempt to await its own update is rejected. Release once on real task completion; | ||
| returning a Response, sending a stream shell, or receiving client close alone is | ||
| not proof that rendering work has settled. R2 must establish completion/abort | ||
| handshakes, including application-owned async work. | ||
|
|
||
| Waiters have individual cancellation/deadlines and a capacity bound. Timeout/full | ||
| queue returns 503, optionally Retry-After; disconnect removes the waiter, not the | ||
| shared update. Handle request bodies/backpressure while waiting and do not replay | ||
| side-effecting actions. Liveness/static traffic independent of the runtime can | ||
| continue; readiness policy must distinguish a bounded update from a dead process. | ||
| Thresholds are configurable and selected from R6 load measurements. | ||
|
|
||
| ## Remaining stage gates | ||
|
|
||
| R0 chooses the ownership and externally observable contracts above. R1 must prove | ||
| shared dependency retention and adapter disposal; R2 must prove stream termination; | ||
| R3/R4 must implement Modern resource ownership and safe publication. None is marked | ||
| implemented by this document. Arbitrary globals, unregistered tasks, native ESM | ||
| registry eviction and deployment-owned worker rotation remain explicit boundaries. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # R0 validation — 2026-09-10 | ||
|
|
||
| This is a regression baseline, not production acceptance. Runtime sources were | ||
| not changed. R1–R6 remain open, as does the existing E2E failure below. | ||
|
|
||
| Source baselines: | ||
|
|
||
| - Core: `7d503c1868cf64445e0f22c9c61a7cb09393ae22` | ||
| - Local Rspack: `8e63776c7a5fa47665fac96f16316f874a18b806` (built version 2.2.2) | ||
| - Local Modern: `46967f66c044572cbd0c7b66a82dd4012220695c` | ||
| - Installed Rspack: `2.2.3-canary-8e63776c-20260908113208` | ||
| - Node 24.18.1, pnpm 10.28.0 | ||
|
|
||
| ## Commands and outcomes | ||
|
|
||
| ```sh | ||
| pnpm exec turbo run build --filter=@module-federation/runtime-tools | ||
| node --test tools/ssr-cache/baseline.test.cjs | ||
| SSR_CACHE_STRICT=1 node --test tools/ssr-cache/baseline.test.cjs | ||
| SSR_CACHE_RSPACK_ENTRY=/Users/bytedance/outter/rspack/packages/rspack/dist/index.js SSR_CACHE_MODERN_ENTRY=/Users/bytedance/work/modern.js/packages/server/core/dist/cjs/adapters/node/index.js node --test tools/ssr-cache/baseline.test.cjs | ||
| pnpm --filter @module-federation/webpack-bundler-runtime run test -- clearCache.spec.ts | ||
| pnpm run ci:local --only=e2e-modern-ssr | ||
| pnpm exec prettier --check tools/ssr-cache | ||
| pnpm exec prettier --check . | ||
| node --check tools/ssr-cache/baseline.test.cjs | ||
| node --check tools/ssr-cache/fixture.cjs | ||
| node --check tools/ssr-cache/modern-fixture.cjs | ||
| git diff --check | ||
| ``` | ||
|
|
||
| - Runtime-tools and dependency builds: 6/6 successful. | ||
| - Installed Rspack baseline: 4 passes, 7 known-defect TODOs, 1 explicit Modern | ||
| skip because no Modern entry path was supplied. No unexpected failures. | ||
| - Local Rspack + Modern: 5 passes, 7 known-defect TODOs, no skips or unexpected | ||
| failures. The HTTP test verifies publication with the same server/PID/port. | ||
| - Strict mode exits 1, as intended: all seven desired-behavior assertions become | ||
| blocking failures (Node also counts their four parent tests as failed). | ||
| - Existing clearCache Jest tests: 6/6 pass. | ||
| - Modern SSR CI: package build 44/44 succeeds; Cypress shared-cache spec passes. | ||
| `remove-remote-cache.cy.js:51` fails because `v1Runtime.captured` is false. | ||
| The preceding v2 payload and absence-of-remove-error assertions pass. The capture | ||
| failure is not diagnosed or fixed by R0, and the CI job must not be called green. | ||
| - Changed-file formatting, JavaScript syntax and diff whitespace checks pass. | ||
| Repository-wide Prettier fails on 683 files, including generated website output, | ||
| generated playground source and a pre-existing modified bridge source. R0 does | ||
| not reformat unrelated files. | ||
|
|
||
| The first CI attempt was interrupted after sandbox DNS failures during dependency | ||
| installation. `pnpm install --frozen-lockfile` was rerun with network access and | ||
| completed; no lockfile changes. A concurrent first Jest attempt during dependency | ||
| recreation could not resolve jsdom; the rerun above passes. The first Modern HTTP | ||
| probe hit sandbox `listen EPERM`; rerunning with local-listener permission passes. | ||
|
|
||
| Full React stream/abort/hydration, production serve under a supervisor, memory/GC | ||
| stability, shared lazy dependencies and multi-entry graph completeness are not | ||
| covered by R0. They remain the explicit R1–R6 acceptance tasks. Other CI jobs were | ||
| not run because this change adds a focused SSR baseline and contracts, not runtime | ||
| behavior. No changeset or package publication is needed for these tooling/docs-only | ||
| changes. The baseline is a documented explicit command, not yet a CI gate. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| const { test } = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const fs = require('node:fs'); | ||
| const os = require('node:os'); | ||
| const path = require('node:path'); | ||
| const { spawnSync } = require('node:child_process'); | ||
|
|
||
| // Each case owns a process: MF globals must not leak between compilations. | ||
| function run(script, directory, flags = {}) { | ||
| const child = spawnSync(process.execPath, [path.join(__dirname, script)], { | ||
| env: { | ||
| ...process.env, | ||
| PARENTS: '0', | ||
| SHARED: '0', | ||
| CONCAT: '0', | ||
| ...flags, | ||
| SSR_CACHE_CASE_DIR: directory, | ||
| }, | ||
| timeout: 120_000, | ||
| encoding: 'utf8', | ||
| maxBuffer: 8 * 1024 * 1024, | ||
| }); | ||
| assert.ifError(child.error); | ||
| assert.equal(child.status, 0, child.stderr + child.stdout); | ||
| } | ||
|
|
||
| // A known defect is not a passing feature test. Strict mode makes all TODOs | ||
| // release-blocking. Unexpected passes also fail so the TODO must be removed. | ||
| async function knownFailure(t, name, verify) { | ||
| if (process.env.SSR_CACHE_STRICT === '1') return t.test(name, verify); | ||
| let failure; | ||
| try { | ||
| verify(); | ||
| } catch (error) { | ||
| failure = error; | ||
| } | ||
| assert.ok(failure, `${name}: now passes; remove its TODO designation`); | ||
| await t.test( | ||
| name, | ||
| { todo: 'R1: known defect, blocks production acceptance' }, | ||
| () => { | ||
| throw failure; | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| for (const [variant, flags] of Object.entries({ | ||
| plain: {}, | ||
| concat: { CONCAT: '1' }, | ||
| parents: { PARENTS: '1' }, | ||
| shared: { SHARED: '1' }, | ||
| })) { | ||
| test(`real Rspack artifacts: ${variant}`, async (t) => { | ||
| const directory = fs.realpathSync( | ||
| fs.mkdtempSync(path.join(os.tmpdir(), 'mf-ssr-cache-')), | ||
| ); | ||
| try { | ||
| run('fixture.cjs', directory, flags); | ||
| const result = JSON.parse( | ||
| fs.readFileSync(path.join(directory, 'result.json'), 'utf8'), | ||
| ); | ||
| t.diagnostic( | ||
| JSON.stringify({ | ||
| variant, | ||
| rspack: result.rspackVersion, | ||
| entry: result.rspackEntry, | ||
| node: result.nodeVersion, | ||
| }), | ||
| ); | ||
| assert.equal( | ||
| result.static.savedHandler, | ||
| 'v1', | ||
| 'cache invalidation cannot mutate a retained function', | ||
| ); | ||
| assert.equal( | ||
| result.static.otherSame, | ||
| true, | ||
| 'unrelated exports retain identity', | ||
| ); | ||
| assert.equal(result.static.executions.other, 1); | ||
| if (variant === 'plain' || variant === 'shared') { | ||
| await knownFailure(t, 'reacquiring a static page returns v2', () => | ||
| assert.equal(result.static.reimport, 'v2'), | ||
| ); | ||
| } else { | ||
| assert.equal(result.static.reimport, 'v2'); | ||
| if (variant === 'parents') { | ||
| for (const module of ['page', 'middle', 'leaf']) | ||
| assert.equal(result.static.executions[module], 2); | ||
| assert.equal(result.rebuild.afterRebindingHook.result, 'v1'); | ||
| assert.equal(result.rebuild.afterRebindingHook.newClearCalls, 1); | ||
| } | ||
| } | ||
| if (variant === 'shared') { | ||
| await knownFailure( | ||
| t, | ||
| 'shared retention does not skip host invalidation', | ||
| () => assert.equal(result.static.withPageInvalidated, 'v2'), | ||
| ); | ||
| await knownFailure( | ||
| t, | ||
| 'consumed shared export retains strict identity', | ||
| () => assert.equal(result.shared.sameObject, true), | ||
| ); | ||
| } else { | ||
| assert.equal(result.dynamic.savedHandler, 'v1'); | ||
| assert.equal(result.dynamic.reimport, 'v1'); | ||
| assert.equal(result.dynamic.mappingContainsDynamic, false); | ||
| assert.equal(result.rebuild.dynamicResult, 'v2'); | ||
| assert.equal(result.rebuild.newBundler, true); | ||
| assert.equal(result.rebuild.hostChanged, false); | ||
| await knownFailure( | ||
| t, | ||
| 'second update clears the current bundler', | ||
| () => { | ||
| assert.equal(result.rebuild.secondUpdate.oldClearCalls, 0); | ||
| assert.equal(result.rebuild.secondUpdate.newClearCalls, 1); | ||
| assert.equal(result.rebuild.secondUpdate.result, 'v1'); | ||
| }, | ||
| ); | ||
| } | ||
| if (variant === 'plain') { | ||
| await t.test( | ||
| 'Modern resource publication keeps server/PID/port', | ||
| { | ||
| skip: | ||
| !process.env.SSR_CACHE_MODERN_ENTRY && | ||
| 'Set SSR_CACHE_MODERN_ENTRY to a built Modern Node adapter', | ||
| }, | ||
| () => { | ||
| run('modern-fixture.cjs', directory); | ||
| const modern = JSON.parse( | ||
| fs.readFileSync( | ||
| path.join(directory, 'modern-result.json'), | ||
| 'utf8', | ||
| ), | ||
| ); | ||
| assert.equal(modern.recreateResources.text, 'v2'); | ||
| for (const key of [ | ||
| 'pidUnchanged', | ||
| 'portUnchanged', | ||
| 'manifestReplaced', | ||
| 'mfInstanceReused', | ||
| ]) | ||
| assert.equal(modern[key], true, key); | ||
| }, | ||
| ); | ||
| } | ||
| } finally { | ||
| fs.rmSync(directory, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the host is rebuilt after
remotechanges from v1 to v2, the emitted host still contains its original v1 startup configuration. The fixture records this scenario asresult.rebuild.staticResult, but this block checks onlydynamicResult; becausedynamicwas registered solely at runtime and is absent from the startup configuration, that assertion cannot detect startup configuration overwriting an accepted update for an existing remote. Such a regression would therefore pass the baseline despite violating the documented rebuild contract, so assert thatstaticResultremainsv2as well.Useful? React with 👍 / 👎.