fix(core): stop rebuilding a headless component's output on every re-render - #8981
Conversation
🦋 Changeset detectedLatest commit: 8436357 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
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 |
d9ee98e to
67ba99e
Compare
@qwik.dev/core
@qwik.dev/router
@qwik.dev/utils
eslint-plugin-qwik
create-qwik
@qwik.dev/optimizer
@qwik.dev/devtools
commit: |
Varixo
left a comment
There was a problem hiding this comment.
Hello, thank you for the PR. I added some comments.
However additional wrapper is a bigger problem than it looks. This will have big serialization impact.
how about something easier. We could create no fragment wrapper at all.
- lets do injectPlaceholderElement returning only [jsx, placeholder] for any case
- createPlaceholderScriptNode will get :useOn as a key instead of null
didnt check it but it should work (of course the use-on and yours tests will need changes and removing fragment wrapper from expectations)
|
@Varixo thanks, your idea works! I switched to it.
You were right about the size. SSR output for 50 headless components with
All other comments are fixed. Please review. |
9a2af66 to
73c5b96
Compare
73c5b96 to
b835845
Compare
b835845 to
8436357
Compare
What is it?
Description
Fixes #8980
A component which renders no DOM element of its own gets a placeholder
<script>, so that a document or windowuseOnevent -useVisibleTask$among them - has something to sit on.injectPlaceholderElementput that placeholder into the output in four ways which were wrong:Everything below the component was destroyed and rebuilt on every re-render. The output was wrapped together with the placeholder in a fragment without a key, and a new wrapper was built on every render.
expectVirtualnever matches an unkeyed fragment against the existing tree, so a new vnode was inserted,descend()turned on creation mode, and every component below lost its state and its DOM. After SSR the whole subtree was rebuilt on resume.Nothing a user writes reaches that rule, because the optimizer keys every JSX node in dev and in
production - this wrapper was the only fragment in core without a key.
A component returning a list of nodes lost the placeholder, so its
useVisibleTask$neverran after a resume:
The recursive call returns a new fragment and the caller ignored it. It only worked when
jsx[0]happened to be a fragment, because that branch modified the node in place.
A component returning a signal or a promise lost it too - both fell through to
// For anything else we do nothing.The placeholder was pushed into the node the component returned. For a component returning a
node it does not rebuild - hoisted or shared JSX - that node kept the
<script>for good, andevery other component returning the same node then rendered a
<script>carrying the firstcomponent's task.
The change
Whatever the component returns is now placed, together with the placeholder, in a single fragment
carrying a stable key, and the returned node is never written to:
wrapped in it.
Because the shape of the output no longer decides the shape of the tree, a component which returns a
single node on one render and a list on the next keeps its children instead of rebuilding them.
Behaviour changes
the placeholder as its last child.
qinithandlers run in document order, so a list whose firstitem was a fragment used to run the parent's task before the tasks of the components inside the
list; it now runs after them, which is what a component with a single root has always done. The
VDOM assertion in
should trigger in empty components arrayis updated for this.across re-renders like every other output. The same component without a document or window
useOnevent still gets a new fragment on every render, because unkeyed fragments aredeliberately never matched by
expectVirtual.Not changed
The rule in
expectVirtualitself. Relaxing it would let a fragment reuse the vnode of acomponent standing in the same position, with that component's QRL, props and tasks still
attached; doing it safely needs the diff to know which kind of virtual node it is looking at, and a
vnode does not carry that in a production build. Worth noting that before
eb493deb3("fix: signalwrapper should not rerender") every unkeyed virtual node was re-created, and that commit made an
exception for the other kinds and left fragments behind - so the rule looks like leftover behaviour
rather than a decision. It still affects JSX built at runtime, or shipped by a package compiled with
another toolchain, since that JSX has no keys.
error-boundary.tsalso builds its client side fallback in an unkeyed fragment, so a boundaryshowing its fallback rebuilds that subtree on every re-render. Left alone here, because
resetmaydepend on it.
Tests
Added to
use-visible-task.spec.tsx, each running under bothdomRenderandssrRenderToDom, allof them failing on
main:should not re-create the root child component on re-render- the child records the prop it wasborn with, so
vtrue|vfalseproves the instance survived; it also asserts the<span>is the sameDOM node after a resume
should not re-create the root child when output changes from child to arrayshould trigger in empty components returning a list of components- the anchor is rendered, andunder SSR it carries the QRL rather than an empty attribute
should not modify a jsx node which the component returns- the returned node is untouched, andthe component which registered nothing renders no
<script>should keep every instance of a headless component when there are many of them- twenty of themon one page, keyed and unkeyed, none mixed up by the shared wrapper key
should trigger in empty components array- updated for the new placeholder positionChecklist
pnpm change