Skip to content

feat(react): route element-template background tree via multi-slot#2651

Open
upupming wants to merge 12 commits into
mainfrom
et-multi-slots
Open

feat(react): route element-template background tree via multi-slot#2651
upupming wants to merge 12 commits into
mainfrom
et-multi-slots

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented May 16, 2026

Summary

Applies the multi-slot pattern from #1764 to element-template mode so the background tree no longer needs BackgroundElementTemplateSlot wrappers between a template instance and its dynamic children.

  • BackgroundElementTemplateInstance now carries __slotIndex (set by Preact via $0/$1 props); insertBefore/removeChild route (parent, slotId, child) ops off that field directly — no <slot> wrapper required.
  • Main-thread renderToOpcodes recognizes $N props and emits __OpSlot N opcodes; legacy children array stays as fallback for older bundles.
  • SWC swc_plugin_element_template lowering emits $N={...} JSX attrs instead of __etSlot(N, ...) wrapper calls. slot.rs is deleted; __etSlot runtime export is retained for legacy bundles.

Test plan

  • cargo test -p swc_plugin_element_template — 33 + 16 passing (added two new transform tests verifying $N lowering for JS and LEPUS targets)
  • CI=true pnpm vitest --config __test__/element-template/vitest.config.ts --run __test__/element-template/runtime/background/instance.test.ts — 76 passing (added 4 multi-slot insert/remove + cross-slot anchor tests)
  • CI=true pnpm vitest --config __test__/element-template/vitest.config.ts --run __test__/element-template/runtime/render/render-to-opcodes.et.test.jsx — 17 passing (added 2 named-slot prop tests)
  • CI=true pnpm vitest --config __test__/element-template/vitest.config.ts --run __test__/element-template/runtime/background/hydrate.test.ts — 31 passing
  • CI=true pnpm vitest --run in packages/react/runtime — 612 passing (standard snapshot suite unaffected)
  • INSTA_UPDATE=always regenerated 9 __combined_snapshots__/*.snap fixtures — verified outputs are <_et_xxx $0={...} $1={...} /> with no __etSlot import

The ET suite still has 69 pre-existing failures (compiled-fixtures, hydration fixtures, mock template tree) that reproduce identically on origin/main without this change; they are environment / ReactLynx.snapshotCreatorMap setup issues outside the scope of this PR.

Summary by CodeRabbit

  • Refactor
    • Overhauled internal Element Template runtime to use a unified multi-slot model and removed legacy slot-wrapper behavior; public APIs and defaults unchanged.
  • Tests
    • Updated many element-template tests and fixtures to reflect multi-slot lowering and new slot serialization.
  • Documentation
    • Updated changelog entry noting no package release required for this unpublished experimental path.

Review Change Stack

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 16, 2026

🦋 Changeset detected

Latest commit: 3fb1c6b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR removes the legacy __etSlot wrapper-based slot model and BackgroundElementTemplateSlot class, migrating to a unified multi-slot mechanism driven by per-instance __slotIndex fields. The SWC compiler now emits named-slot props ($0, $1, ...) instead of slot wrapper calls; the background runtime routes child operations and generates opcodes using __slotIndex. Tests and fixtures are updated throughout to validate the new model and reflect changed opcode values.

Changes

Multi-Slot Element Template Migration

Layer / File(s) Summary
SWC Compiler: Slot Module Removal & Named-Slot Lowering
packages/react/transform/crates/swc_plugin_element_template/slot.rs, lib.rs, lowering.rs, tests/element_template.rs, packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts
Removes the slot.rs module entirely, eliminates __etSlot import injection from JSXTransformer, and refactors lower_runtime_jsx to emit $N attributes for dynamic children instead of wrapping them in slot containers. Transform tests validate named-slot output and absence of legacy wrapper behavior.
Background Instance API: Multi-Slot Routing via __slotIndex
packages/react/runtime/src/element-template/background/document.ts, instance.ts
Adds public __slotIndex: number field (default 0) to route multi-slot operations; removes BackgroundElementTemplateSlot class. Updates insertBefore/removeChild to sync children by slot index, compute same-slot anchors, guard patch emission via canEmitPatch(), and emit insertNode/removeNode ops keyed by derived slotId. Removes slot-specific attribute/collection logic.
Runtime Module Exports & Component Removal
packages/react/runtime/src/element-template/runtime/components/slot.ts, internal.ts
Removes the exported __etSlot(id, children) function; removes __etSlot export from internal re-exports and adds __etAttrPlanMap, adaptEventAttrSlot, adaptSpreadAttrSlot exports.
Render Path: Named-Slot Props Parsing
packages/react/runtime/src/element-template/runtime/render/render-to-opcodes.ts
Changes renderCompiledEtHostVNode to scan props for $<int>-keyed entries, build a sparse slot children array by index, and render via renderEtSlotArray; removes logic treating props.children as the slot array.
Render Fixture Updates: Named-Slot Props
packages/react/runtime/__test__/element-template/fixtures/render/*/index.js.txt
Updates compiled render fixture outputs to pass dynamic children via $0, $1, $2 placeholder props instead of children arrays; covers child-siblings, component-slot-content, component, mapped-view-children, mixed-children, multiple-text, nested-templates, react-example.
Opcode Render Tests & Validation
packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx
Adds test cases validating ET host node opcode emission for $N named props with correct slot indices, contiguous and sparse slot handling, and opcode/text pair ordering.
Background Instance Tests: Multi-Slot & Removal
packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts
Removes BackgroundElementTemplateSlot usage; refactors insertion, removal, and deferral tests to use __slotIndex routing. Adds comprehensive multi-slot insert/remove coverage validating same-slot vs cross-slot beforeChild anchoring, commit payload keying, and elementSlots state transitions.
Background Hydrate Tests: Slot Index Routing
packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts
Removes slot wrapper containers from test setups, uses __slotIndex for slot position simulation, and updates assertions from empty arrays ([]) to undefined for sparse/unmaterialized slots. Refactors 20+ test cases covering multiple dynamic slots, cross-slot moves, nested insertion, and hydrate candidate handling.
Other Test Suite Updates
packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx, commit-context.test.ts, hydration-listener.test.ts, packages/react/runtime/__test__/element-template/runtime/components/slot.test.ts
Removes __etSlot tests and imports from adapter test; updates commit-context and hydration-listener fixtures to use direct instance attachment; removes entire __etSlot Vitest suite.
Hydrate Fixture Setup: Direct Child Routing
packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx
Removes BackgroundElementTemplateSlot wrapper construction; appends children directly to root using __slotIndex for slot position simulation. Updates 8+ hydration test case fixtures (missing-slot-record, creates-missing-nodes, raw-text instances, move-before-child, iterates-existing-slots).
Fixture Output & Stream Updates
packages/react/runtime/__test__/element-template/fixtures/*/output.txt, ops.txt
Updates expected fixture output values across hydrate, patch, and compiled test cases; numeric stream/ops values change due to eliminated slot wrapper overhead and new multi-slot routing model.
Test Fixture Import & Export Updates
packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts, ops/supports-silent-insert-before/case.ts, packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt
Removes BackgroundElementTemplateSlot imports and re-exports; updates test setup to use direct root instance insertion instead of slot-wrapper paths.
Webpack Loader Test & Release Notes
packages/webpack/react-webpack-plugin/test/background-loader.test.ts, .changeset/et-multi-slots.md
Updates ET-enabled test to identify ET path via _et_<hash>_ template-id pattern and assert __etSlot absence. Changeset clarifies no package release needed; internal-only changes under experimental_useElementTemplate flag.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Suggested Reviewers

  • HuJean
  • hzy
  • Yradex
  • gaoachao
  • colinaaa

Poem

🐰 I hopped through code with a tiny twitch,
Goodbye to wrappers, goodbye to the glitch.
Now $0 and friends jump into place,
__slotIndex guides them—swift little race.
Hooray—slots found their new hiding niche!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(react): route element-template background tree via multi-slot' clearly and specifically describes the main change: routing the element-template background tree via multi-slot pattern, eliminating the need for slot wrapper elements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch et-multi-slots

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 84.00000% with 16 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...te/fixtures/hydrate/background-hydrate/_shared.tsx 0.00% 14 Missing ⚠️
...instance/ops/supports-silent-insert-before/case.ts 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 16, 2026

Merging this PR will improve performance by 7.42%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 80 untouched benchmarks
⏩ 26 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
004-various-update__main-thread-setAttribute__MT_Ref 139.3 µs 129.7 µs +7.42%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing et-multi-slots (3fb1c6b) with main (2061597)

Open in CodSpeed

Footnotes

  1. 26 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 16, 2026

React Example with Element Template

#683 Bundle Size — 199.79KiB (-0.14%).

3fb1c6b(current) vs 2061597 main#681(baseline)

Bundle metrics  Change 3 changes
                 Current
#683
     Baseline
#681
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 27.15% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 89(-2.2%) 91
No change  Duplicate Modules 27 27
Change  Duplicate Code 39.77%(-0.03%) 39.78%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Improvement 1 improvement
                 Current
#683
     Baseline
#681
No change  IMG 145.76KiB 145.76KiB
Improvement  Other 54.03KiB (-0.53%) 54.32KiB

Bundle analysis reportBranch et-multi-slotsProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 16, 2026

React MTF Example

#1547 Bundle Size — 208.12KiB (0%).

3fb1c6b(current) vs 2061597 main#1545(baseline)

Bundle metrics  no changes
                 Current
#1547
     Baseline
#1545
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 193 193
No change  Duplicate Modules 77 77
No change  Duplicate Code 44.24% 44.24%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#1547
     Baseline
#1545
No change  IMG 111.23KiB 111.23KiB
No change  Other 96.89KiB 96.89KiB

Bundle analysis reportBranch et-multi-slotsProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 16, 2026

React External

#1529 Bundle Size — 695.4KiB (0%).

3fb1c6b(current) vs 2061597 main#1527(baseline)

Bundle metrics  no changes
                 Current
#1529
     Baseline
#1527
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 17 17
No change  Duplicate Modules 5 5
No change  Duplicate Code 8.59% 8.59%
No change  Packages 0 0
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#1529
     Baseline
#1527
No change  Other 695.4KiB 695.4KiB

Bundle analysis reportBranch et-multi-slotsProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 16, 2026

Web Explorer

#9989 Bundle Size — 901.94KiB (0%).

3fb1c6b(current) vs 2061597 main#9986(baseline)

Bundle metrics  no changes
                 Current
#9989
     Baseline
#9986
No change  Initial JS 45.06KiB 45.06KiB
No change  Initial CSS 2.22KiB 2.22KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 9 9
No change  Assets 11 11
No change  Modules 229 229
No change  Duplicate Modules 11 11
No change  Duplicate Code 27.19% 27.19%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#9989
     Baseline
#9986
No change  JS 497.56KiB 497.56KiB
No change  Other 402.16KiB 402.16KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch et-multi-slotsProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 16, 2026

React Example

#8414 Bundle Size — 237.17KiB (0%).

3fb1c6b(current) vs 2061597 main#8412(baseline)

Bundle metrics  no changes
                 Current
#8414
     Baseline
#8412
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 4 4
No change  Modules 198 198
No change  Duplicate Modules 80 80
No change  Duplicate Code 44.73% 44.73%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#8414
     Baseline
#8412
No change  IMG 145.76KiB 145.76KiB
No change  Other 91.42KiB 91.42KiB

Bundle analysis reportBranch et-multi-slotsProject dashboard


Generated by RelativeCIDocumentationReport issue

upupming added 5 commits May 18, 2026 11:17
Element-template's background runtime now consumes Preact's __slotIndex
directly instead of going through a BackgroundElementTemplateSlot wrapper.
The SWC swc_plugin_element_template lowering emits $0, $1, ... named-slot
JSX props in place of __etSlot(N, ...) wrapper-call children, so the
main-thread renderToOpcodes and the background insertBefore/removeChild
both key insert and remove ops off the child's own slot index. The legacy
BackgroundElementTemplateSlot is kept for bundles compiled before this
change.
Regenerated via `UPDATE=1 pnpm vitest` after the multi-slot lowering:
- `render/*/index.js.txt` switch `children: [...]` to `$0: ...`/`$N: ...` JSX props.
- `background/render/.../output.txt` drops the `<slot id=N>` wrappers from the
  background tree serialization.
- `patch-compiled/*/ops.txt` and `hydrate/.../output.txt` reflect the new
  instance-id ordering now that slot wrappers are no longer registered.
…rtion

- mockNativePapi/templateTree.test.ts used a `kind: 'attribute' | 'spread', binding: ...` schema that doesn't match what the SWC transform emits (`kind: 'static' | 'slot' | 'spread'`), so the spread-vs-slot ordering case asserted the wrong descriptor was being treated as `spread`. Align with the production schema.
- webpack/react-webpack-plugin's background-loader test required an `@lynx-js/react/element-template[/internal]` import to confirm ET was active, which only existed because the old lowering emitted an `__etSlot(N, ...)` runtime call. With multi-slot lowering the import is no longer needed; assert on the `_et_<hash>_` template-id prefix instead.
…anches

- BackgroundElementTemplateInstance.insertBefore/removeChild no longer fall
  through to a trailing `if (silent) return` after the multi-slot branch
  short-circuits — the implicit return at end-of-function is equivalent.
- Mark the `slotId < 0` guard in syncMultiSlotChildren and the non-numeric
  `$N` slot id branch in render-to-opcodes.ts with `v8 ignore` since the
  transform never emits them.
- Add adapter tests for `createElementNS('slot')` and a legacy-slot
  `insertBefore` test with a non-null anchor so the slot wrapper code paths
  stay covered at 100%.
The slot wrapper class only existed to back the old `__etSlot(N, ...)`
runtime call emitted by the pre-multi-slot lowering. The new lowering does
not emit it, no consumer creates `<slot>` JSX, and `experimental_useElementTemplate`
has no published bundles relying on the old path — so remove the class
outright.

- `BackgroundElementTemplateInstance.insertBefore`/`removeChild` collapse
  to the single multi-slot path keyed off `child.__slotIndex`.
- `document.ts` no longer special-cases `type === 'slot'`.
- `__etSlot()` runtime component and its `internal.ts` re-export are deleted.
- Tests/fixtures that built trees via slot wrappers were rewritten to
  attach children directly with `__slotIndex`; obsolete describe blocks
  asserting slot-wrapper internals were dropped.
- Four hydrate fixture `output.txt`s were regenerated because removed
  slot wrappers no longer consume instance ids.
upupming added 2 commits May 18, 2026 11:26
`children.iterates-existing-slots` and `children.missing-attrs-element`
attached then silently removed dummy children only to materialize empty
slot indexes on `instance.elementSlots`. The hydrate loop iterates over
`Math.max(serialized.length, instance.length)` and treats an empty
serialized `[]` slot as truthy, so background-side materialization isn't
required — the loop fires either way and the test outcomes are
identical without the dance.
@upupming upupming marked this pull request as ready for review May 18, 2026 03:53
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99a30b465f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/et-multi-slots.md:
- Around line 1-5: The changeset file currently contains only the YAML
delimiters `---` with no package entries between them; update
`.changeset/et-multi-slots.md` to follow project conventions by either removing
the changeset entirely if no package needs a version bump, or add at least one
package entry and version (e.g., add an entry like `"`@lynx-js/`<package-name>":
patch`) between the `---` delimiters so the changeset tool recognizes it; ensure
the top/bottom `---` remain and the package key is a valid package name from the
repo.

In `@packages/react/runtime/src/element-template/background/instance.ts`:
- Around line 196-200: The code sets beforeId = 0 for cross-slot anchors (when
beforeChild.__slotIndex !== slotId) but still inserts locally using beforeChild,
causing elementSlots[slotId] to diverge; update the insertion logic in the same
routine (where beforeChild, beforeId, elementSlots, slotId, and instanceId are
used) so that when beforeChild.__slotIndex !== slotId you treat the anchor as
null for both emitted op and local mutation — i.e., use the same fallback
(append at tail) for elementSlots[slotId] insertion as you do for beforeId to
keep local and main-thread order consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 35d39757-cd1f-4d72-ad69-b9dc38927c22

📥 Commits

Reviewing files that changed from the base of the PR and between 676d1b3 and 99a30b4.

⛔ Files ignored due to path filters (9)
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap is excluded by !**/*.snap
  • packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap is excluded by !**/*.snap
📒 Files selected for processing (42)
  • .changeset/et-multi-slots.md
  • packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts
  • packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/supports-silent-insert-before/case.ts
  • packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.creates-and-inserts-new/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.inserts-before-existing-sibling/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.mixed-operations/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.non-string-raw-text-key-on-main/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/complex-trees.deeply-nested-dynamic-content/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.creates-missing-nodes-recursively/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.missing-slot-record-on-main/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.move-before-child/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.raw-text-key-branches/output.txt
  • packages/react/runtime/__test__/element-template/fixtures/patch-compiled/applies-insert-before-with-reference/ops.txt
  • packages/react/runtime/__test__/element-template/fixtures/patch-compiled/apply-hydration-ops/ops.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/component/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/index.js.txt
  • packages/react/runtime/__test__/element-template/fixtures/render/react-example/index.js.txt
  • packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx
  • packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts
  • packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts
  • packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts
  • packages/react/runtime/__test__/element-template/runtime/components/slot.test.ts
  • packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts
  • packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx
  • packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts
  • packages/react/runtime/src/element-template/background/document.ts
  • packages/react/runtime/src/element-template/background/instance.ts
  • packages/react/runtime/src/element-template/internal.ts
  • packages/react/runtime/src/element-template/runtime/components/slot.ts
  • packages/react/runtime/src/element-template/runtime/render/render-to-opcodes.ts
  • packages/react/transform/crates/swc_plugin_element_template/lib.rs
  • packages/react/transform/crates/swc_plugin_element_template/lowering.rs
  • packages/react/transform/crates/swc_plugin_element_template/slot.rs
  • packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs
  • packages/webpack/react-webpack-plugin/test/background-loader.test.ts
💤 Files with no reviewable changes (5)
  • packages/react/runtime/test/element-template/runtime/components/slot.test.ts
  • packages/react/runtime/src/element-template/runtime/components/slot.ts
  • packages/react/runtime/src/element-template/internal.ts
  • packages/react/transform/crates/swc_plugin_element_template/slot.rs
  • packages/react/transform/crates/swc_plugin_element_template/lib.rs

Comment thread .changeset/et-multi-slots.md
Comment thread packages/react/runtime/src/element-template/background/instance.ts Outdated
…-tail

When `parent.insertBefore(child, beforeChild)` is called with a cross-slot
`beforeChild`, the op emits `beforeId = 0` so the main-thread slot
appends `child` to its tail. The local sibling chain previously still
inserted `child` literally before `beforeChild`, which made
`syncMultiSlotChildren` filter the chain by `__slotIndex` into
`[..., child, existingSlotTail]` while main thread saw
`[..., existingSlotTail, child]`. Subsequent ops keyed off the local
order would then desync from main.

Treat a cross-slot `beforeChild` as null for the linked-list mutation
too, so both sides see append-tail. Same-slot anchors still honor the
insertion position. Reported by CodeRabbit on PR #2651.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b295b6e63b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/runtime/src/element-template/internal.ts
// it pending and tear it down on the Snapshot-aligned delayed boundary.
markRemovedSubtreeForCurrentCommit(child);
}
const slotId = child.__slotIndex;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think syncing only child.__slotIndex is enough here. For a moved existing child, the slot index can already point to the destination slot by the time removeChild() runs, so the previous source slot can keep a stale reference.

Can we either track the slot the child was attached under, or resync both/all affected slot arrays after a move?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems
root.render(<_et_host $2={<_et_child key="moved" />} />)
root.render(<_et_host $0={<_et_child key="moved" />} />)
results in host slots
[ [ '_et_child:3:0' ], <1 empty item>, [ '_et_child:3:0' ] ]
and the node is not removed from elementSlots[2]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed and fixed in 1c9d75c. Preact's insert() mutates child.__slotIndex to the destination before our insertBefore runs, which triggers child.parent.removeChild(child, true) for the rebind. Reading child.__slotIndex inside removeChild only sees the NEW slot, so the source slot stayed stale.

Updated removeChild to gather every elementSlots[i] that still mentions the child, then resync all of them:

const slotsToResync = new Set<number>([child.__slotIndex]);
for (let i = 0; i < this.elementSlots.length; i += 1) {
  if (this.elementSlots[i]?.includes(child)) {
    slotsToResync.add(i);
  }
}
for (const id of slotsToResync) {
  syncMultiSlotChildren(this, id);
}

This handles both directions (source slot → destination slot, and the in-place no-op when there's no move). Two regression tests added in instance.test.ts (per your other comment) — see reply on instance.test.ts:1163.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about

root.render(<_et_host $1={<_et_child key="moved" />} />)
root.render(<_et_host $0={<_et_child key="moved" />} />)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried the root.render(<_et_host $1={...} />)root.render(<_et_host $0={...} />) shape end-to-end, but Preact's findMatchingIndex short-circuits before the keyed-cross-slot path is taken in this shape. Walking the diff for the JSX

function MovingChild({ slot }) {
  const moved = <text key="moved">moved</text>;
  return (
    <view>
      <view id="top">{slot === 0 ? moved : null}</view>
      <view id="bottom">{slot === 1 ? moved : null}</view>
    </view>
  );
}

which compiles to _jsx(_et_host, { $0: slot === 0 ? moved : null, $1: slot === 1 ? moved : null }):

  • Render 1 (slot=1) sets newChildren = [null, movedVNode]. Preact mounts moved DOM at slotIndex 1.
  • Render 2 (slot=0) sets newChildren = [movedVNode2, null] and oldChildren = [null, oldMoved].
  • diffChildren walks i=0: findMatchingIndex(newMoved, oldChildren, skewedIndex=0, …) evaluates the first branch:
    if (oldVNode === NULL || oldVNode && key == oldVNode.key && ) return skewedIndex;
    oldChildren[0] is null, so it returns 0 immediately — no keyed search across positions. The new moved diffs against EMPTY_OBJ (fresh mount at slot 0), and the old moved at index 1 falls to the trailing unmount loop.

Empirically I confirmed the same: after render(slot=0) the previously-rendered _et_text#3 is still at host.elementSlots[1] with __slotIndex=1 — Preact never observes a cross-slot move because null at the new home index acts as a "claim this position freshly". (Slot-1 cleanup happens through the regular insertion in slot 0 + unmount of the old at slot 1, which doesn't exercise the bug.)

For the bug to surface end-to-end I'd need Preact to update an existing DOM's __nextSlotIndex while keeping the same __e. The cleanest place where that does happen is when Preact's diff matches a keyed child whose new old position is a real (non-null) old vnode of a different __slotIndex — which is what compiled-keyed-list / compiled-keyed-subtree-list already cover for in-slot moves.

The two instance.test.ts regressions (clears the source slot when an existing child moves to an adjacent slot / … non-adjacent slot) drive insertBeforeremoveChild with child.__slotIndex mutated in advance, which is exactly the post-insert() state Preact lands the runtime in for a cross-slot keyed move — so they cover the bug's mechanism without needing the artificial Preact shape. Happy to look at a different scenario if you have one in mind that would surface here at the root.render level.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right — chased it down. Added an end-to-end test in 3fb1c6b that does drive the rebind through Preact's diff, plus a fuller writeup of what I found:

Two-element swap doesn't surface the fix. With [null, X] → [X, null] (my first attempt) findMatchingIndex short-circuits on oldChildren[0] === NULL and returns skewedIndex immediately; Preact then unmounts the old X at index 1 and fresh-mounts at 0. With [a, b] → [b, a] (the swap you proposed) findMatchingIndex does find the key, but matchingIndex == skewedIndex ± 1 triggers Preact's skew shortcut — __nextSlotIndex is set on the moved DOM but insert() is not called for it (only nodes flagged INSERT_VNODE go through insert() in constructNewChildrenArraydiffChildren). So __slotIndex is never propagated and the runtime never observes a rebind.

Three-element rotate is the smallest shape that hits the fix. [a, b, c] → [c, a, b] makes c's match land at skewedIndex + 2, falls into the else branch in constructNewChildrenArray that sets INSERT_VNODE, so insert() runs the cross-slot branch (__nextSlotIndex != __slotIndex), mutates c.__slotIndex to the destination, and calls parentDom.insertBefore — which our insertBefore handles via removeChild(c, silent=true). The test asserts host.elementSlots[2] is cleared even though only that silent removeChild ran for the rebind, which is exactly what the source-slot resync delivers.

The skew == ±1 shortcut leaving a/b at their old __slotIndex after the rotate is a separate upstream Preact concern (the optimization assumes single-DOM-container semantics and doesn't propagate per-slot indices). Outside this PR's scope.


expect(parent.elementSlots[0]).toEqual([slot0Tail, incoming]);
expect(parent.lastChild).toBe(incoming);
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add coverage for moving an already-attached child between slots? The important assertions are that the destination slot gets the child and the old source slot no longer contains it. It would be good to cover both adjacent and non-adjacent slot changes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1c9d75c, covering both adjacent (0 → 1) and non-adjacent (0 → 3) cross-slot moves:

it('clears the source slot when an existing child moves to an adjacent slot', () => {
  const parent = new BackgroundElementTemplateInstance('view');
  const child = new BackgroundElementTemplateInstance('text');
  child.__slotIndex = 0;
  parent.appendChild(child);
  expect(parent.elementSlots[0]).toEqual([child]);

  // Preact's `insert()` updates the child's `__slotIndex` to the destination
  // before re-attaching it via parent.insertBefore.
  child.__slotIndex = 1;
  parent.insertBefore(child, null);

  expect(parent.elementSlots[0]).toEqual([]);
  expect(parent.elementSlots[1]).toEqual([child]);
});

it('clears the source slot when an existing child moves to a non-adjacent slot', () => {
  // ... 0 → 3, with assertions on each intervening slot
  expect(parent.elementSlots[0]).toEqual([]);
  expect(parent.elementSlots[1]).toBeUndefined();
  expect(parent.elementSlots[2]).toBeUndefined();
  expect(parent.elementSlots[3]).toEqual([child]);
});

Both fail before the removeChild fix and pass after.

if (hasNamedSlotChildren) {
renderEtSlotArray(namedSlotChildren, context, vnode, opcodes);
} else {
// Legacy ET host nodes still encode dynamic element slots in `children`,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need the legacy children slot-array fallback here? If ET hosts are now expected to use $N props only, I think this fallback can be removed so the renderer has a single input contract.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — removed in 1c9d75c. The renderer now only consumes $N props; the body simplified to:

let namedSlotChildren;
for (const name in props) {
  if (typeof name !== 'string' || name.charCodeAt(0) !== 36 /* '$' */) continue;
  const slotId = +name.slice(1);
  if (!(slotId >= 0)) continue;
  (namedSlotChildren ??= [])[slotId] = props[name];
}
if (namedSlotChildren !== undefined) {
  renderEtSlotArray(namedSlotChildren, context, vnode, opcodes);
}

If a future call hands the renderer a host node without $N props (and without children), it simply emits a __OpBegin/__OpEnd pair — same as before. Single input contract going forward.

]);
});

it('prefers $N named props over legacy children when both are present', () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove the legacy children fallback, this coverage should go away too. The renderer tests should focus on the $N input shape instead of keeping the old slot-array contract alive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1c9d75c. Dropped both legacy-shape tests (should emit slot opcodes for ET host slot arrays, should skip empty slot entries when rendering ET host slot arrays) and the prefers $N over legacy children precedence test — none of those input shapes are reachable now. The remaining renderer tests focus on the $N contract:

  • emits slot opcodes for ET host nodes using $N named props
  • skips empty $N slots in ET host nodes (the sparse-slot case, e.g. only $1 and $4 set)

slot0.setAttribute('id', 0);
rootInstance.appendChild(slot0);
const slot1 = new BackgroundElementTemplateSlot();
slot1.setAttribute('id', 1);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture name looks stale now. The setup no longer creates any existing background slot entry, so it is not really testing “existing slots” anymore. Can we either rename it or materialize an actual empty/sparse background slot?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed in 1c9d75c to children.no-op-when-both-sides-have-empty-slots. The setup matches the new name — rootInstance.elementSlots starts empty, serialized side ships [[], []], and the assertion verifies the hydrate loop iterates both empty arrays without emitting anything. Fixture directory and runCaseByName(...) reference were updated together.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up — after the rename @upupming noticed offline that the renamed case was now functionally identical to children.missing-attrs-element (both ended up as trivial no-ops once the slot-wrapper materialization was removed). Reverted the rename in ebdef8f and instead made the test actually exercise the Math.max(serializedLen, instanceLen) branch in hydrate.ts:

const rootInstance = new BackgroundElementTemplateInstance('root');
const slot1Child = new BackgroundElementTemplateInstance('child');
slot1Child.__slotIndex = 1;
rootInstance.appendChild(slot1Child);

const before = createHydrationTemplate(-1, 'root', { elementSlots: [[]] });

Background side has a child at slot 1, serialized only ships slot 0. The hydrate loop now visits slot 1 (background-only) and emits an insertion for the background-only child:

stream: [
  1, 2, "child", null, [], [],   // createTemplate
  3, -1, 1, 2, 0,                // insertNode(parent=-1, slot=1, child=2, beforeId=0)
]

Original name preserved, semantic now matches it.

// Multi-slot lowering no longer needs an `__etSlot` runtime call, so the
// ET path is now identified by the `_et_` template id prefix rather than
// by an `@lynx-js/react/element-template[/internal]` import.
expect(result.code).toMatch(/_et_[a-z0-9]+_/);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name does not quite match the assertion anymore. It no longer checks for an ET runtime import; it checks that ET transform happened via the generated template id. Maybe rename it, and add a negative assertion for __etSlot / element-template internal imports.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1c9d75c. Renamed the test to routes ET background output through Preact JSX and the multi-slot lowering when ET is enabled and reorganized the assertions:

  • Positive: stays on @lynx-js/react/jsx-runtime, produces _et_<hash>_ template ids.
  • Negative: no __etSlot, no @lynx-js/react/internal, no @lynx-js/react/element-template/internal, no @lynx-js/react/element-template. None of these should ever appear in multi-slot ET output.

upupming added 4 commits May 18, 2026 15:38
…hten contract

Round of follow-ups for Yradex's review on PR #2651:

- `BackgroundElementTemplateInstance.removeChild` now resyncs every
  elementSlots entry that still references the child, not just the slot
  the child currently believes it's in. Preact's `insert()` mutates
  `child.__slotIndex` to the destination before calling our
  `insertBefore`, so the rebind path's `removeChild(child, true)` would
  previously leave a stale reference in the source slot. Added two
  regression tests covering adjacent (0→1) and non-adjacent (0→3)
  cross-slot moves.
- Drop the legacy `children` slot-array fallback in
  `render-to-opcodes.ts` and its accompanying tests. The transform only
  emits `$N` named props now, so the renderer has a single input
  contract.
- Rename `children.iterates-existing-slots` hydrate fixture to
  `children.no-op-when-both-sides-have-empty-slots` to reflect that the
  background side no longer materializes empty slot entries.
- Rename the webpack `background-loader` test and add explicit negative
  assertions for `__etSlot` and the legacy ET internal/external imports.
…ningful

The renamed `children.no-op-when-both-sides-have-empty-slots` case
was functionally identical to `children.missing-attrs-element` — both
ran with an empty background instance and a serialized payload that
shipped an empty slot array. Restore the original name and give it the
setup it was always supposed to cover: background side carries a child
at slot 1 while serialized side only reports slot 0, exercising the
`slotCount = Math.max(serializedLen, instanceLen)` branch in
`hydrate.ts` so the background-only slot still emits its child as an
insertion to the main thread.
…hot path

Match the shape used in `packages/react/runtime/src/snapshot/renderToOpcodes/index.ts`:

```ts
for (const name in props) {
  if (name.startsWith('$')) {
    (namedSlotChildren ??= [])[+name.slice(1)] = props[name];
  }
}
```

The transform only emits `$<int>` props, so the defensive `slotId >= 0`
guard and the `charCodeAt` micro-optimization were never reachable in
practice. Same behavior, less code.
…ld fix

Per Yradex's suggestion: cover the source-slot cleanup end-to-end through
Preact's diff rather than only at the `insertBefore`/`removeChild` unit
level. A simple two-slot swap doesn't surface the fix because Preact's
`findMatchingIndex` returns the skewedIndex immediately when the old
slot at that position is null, and the `skew==±1` optimization sets the
new `__nextSlotIndex` but skips `insert()` — leaving `__slotIndex` (and
our slot ops) stale. Both are upstream Preact concerns separate from
this PR.

A three-element rotate (`[a, b, c] → [c, a, b]`) shifts `c` by 2
positions, which lands on the `INSERT_VNODE` path and reaches our
runtime through `parentDom.insertBefore`. The new test confirms that:
- `c.__slotIndex` is updated to its new home (slot 0).
- `host.elementSlots[2]` is empty even though Preact only fires the
  rebind via `removeChild(c, silent=true)` — exactly the path the
  source-slot resync was added for.

`a` and `b` stay at their old slot indices because Preact takes the
`skew==±1` shortcut for them; that's outside this fix's scope.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fb1c6b28d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

beforeId,
);
}
syncMultiSlotChildren(this, slotId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle adjacent keyed moves between slots

When keyed children swap between two $N slots on the same ET host, internal Preact can take its adjacent-move shortcut without calling this insertBefore path (the new Preact test even notes that only the non-adjacent child trips INSERT_VNODE). In that scenario __slotIndex and elementSlots are never resynced, so a render like slot0=a, slot1=b followed by slot0=b, slot1=a leaves the background slot map in the old order and subsequent hydrate/update ops patch the wrong slot contents. The slot resync needs to cover the adjacent keyed path as well, not only insertions that reach insertBefore.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants