Skip to content

fix: give ExtensionPoint's SSR placeholder its own tree-path context - #687

Open
iago1501 wants to merge 2 commits into
masterfrom
perf/extensionpoint-preview-treepath
Open

fix: give ExtensionPoint's SSR placeholder its own tree-path context#687
iago1501 wants to merge 2 commits into
masterfrom
perf/extensionpoint-preview-treepath

Conversation

@iago1501

@iago1501 iago1501 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What does this PR do? *

Fixes a CLS bug in ExtensionPoint: its SSR loading placeholder (<Loading />) rendered outside its own tree-path context, so it inherited the parent block's preview size instead of its own. On pages where a page-level block declares a large preview (e.g. a 1400px box) with client-rendered children that have no preview of their own, SSR emitted the parent's oversized placeholder for each child — which then collapsed to its real height on hydration, causing a large layout shift.

Fix: wrap the placeholder in a TreePathContextProvider scoped to the block's own tree path.

📊 Results — tested live on 4 real store accounts

Store Bug present? CLS before → after
🟢 Olympikus Yes 0.1071 → 0.0000 (-100%, 5/5 runs)
🟢 Loja Três Yes 0.699 → 0.0054 (-99%)
⚪ Pague Menos No (control) ~0.97 → ~1.00 (no change, as expected)
⚪ storecomponents No (control) 0.434 → 0.3485 (dominant shift untouched, as expected)

CLS fully eliminated on the two stores that actually have the bug pattern; correctly no-ops on the two that don't. FCP/LCP/DOM node count unchanged everywhere.

How to test it? *

  • Regression test added in ExtensionPoint.test.tsx; tsc --noEmit clean.
  • All numbers above come from A/B testing this branch via vtex link against a dedicated dev workspace on each store's account (baseline vs. fix, same workspace) — never against live production traffic.
  • Independently confirmed via ReactDOMServer (jsdom can't reach this branch): pre-fix SSR emits the parent's 460px preview marker; post-fix output is empty, as expected.
  • QA review passed clean: Bugbot, Security Review, and a performance-focused review all ran against this diff — zero findings (no breaking changes, no security/perf concerns).
Known toolchain caveat

The local suite has 4 pre-existing failures unrelated to this change (pinned @babel/parser 7.7.7 can't parse import type in already-committed code), so the new test couldn't run locally — verified instead via the compiled SSR harness. CI should run it for real.

Describe alternatives you've considered, if any. *

None — straightforward context-scoping fix to existing NoSSR/Loading wiring, not a design trade-off.

Related to / Depends on *

Follow-up candidate (not part of this PR): LazyRender's hardcoded height=400 placeholder can also shift layout for below-fold content of a different real height. Not solidly measured yet (only 3 noisy runs).

iago1501 and others added 2 commits September 4, 2026 13:57
Loading resolves its extension via useExtension(), which reads whatever
tree path is currently in context. The bare <Loading /> passed as NoSSR's
onSSR fallback rendered outside any TreePathContextProvider for this
block (ComponentLoader only installs the provider inside the loaded
content), so it read the *parent's* tree path and drew the *parent's*
preview instead of its own (or none, if it has none).

On pages where a page-level block declares a large preview (e.g. a 1400px
box) and has client-rendered children with no preview of their own, SSR
emits the parent's oversized placeholder for each such child, which then
collapses to its real (often much smaller, sometimes zero) height on
hydration -- a large, page-load-time layout shift.

Measured on Olympikus's home page: CLS 0.1071 -> 0.0000 (5/5 runs, live
A/B on production, mobile/throttled), FCP/LCP/extension count/DOM node
count unchanged. Verified independently at the unit level through the
real ReactDOMServer renderer (jsdom can't reach this branch, since NoSSR's
layout effect fires there and renders children instead): pre-fix SSR
emits the parent's 460px loading-preview marker; post-fix output is empty,
as expected for a child with no preview of its own.

Adds a regression test mirroring the existing 'no extension for current
tree path' test. tsc --noEmit is clean.

Known toolchain caveat: the local test suite has 4 pre-existing failures
unrelated to this change (pinned @babel/parser 7.7.7 chokes on 'import
type' in already-committed code), so the new test could not be executed
locally -- verified via the compiled SSR harness instead; CI should run it.

Also flags a second, uncharacterized CLS source for follow-up: LazyRender's
hardcoded height=400 placeholder causes shifts on scroll for below-fold
content of a different height (scroll-arm CLS 0.527 -> 0.399 was noise-
dominated with only 3 runs, not treated as a solid result here).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖

Please select which version do you want to release:

  • Patch (backwards-compatible bug fixes)

  • Minor (backwards-compatible functionality)

  • Major (incompatible API changes)

And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.

  • No thanks, I would rather do it manually 😞

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new SSR test uses renderToString() under Jest/JsDOM where window exists, which can make NoSSR select useLayoutEffect and produce ReactDOMServer warnings/noise unless the test simulates a real SSR environment.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes a CLS issue in ExtensionPoint SSR by ensuring the client-rendered (render: "client") loading placeholder (<Loading />) resolves preview sizing from the block’s own tree-path context instead of inheriting the parent’s tree path (and thus the parent’s preview dimensions).

Changes:

  • Wrap NoSSR’s onSSR placeholder with TreePathContextProvider scoped to newTreePath.
  • Add a regression test that server-renders an ExtensionPoint to ensure a client-rendered child with no preview does not render the parent preview placeholder.
  • Document the fix in the changelog.
File summaries
File Description
react/components/ExtensionPoint/index.tsx Wraps the SSR placeholder in TreePathContextProvider using the block’s newTreePath to prevent inheriting parent preview size.
react/components/ExtensionPoint/ExtensionPoint.test.tsx Adds an SSR regression test verifying parent preview isn’t rendered for a client-only child with no preview.
CHANGELOG.md Adds an Unreleased “Fixed” entry describing the SSR placeholder tree-path scoping fix and its CLS impact.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +68 to +78
/* Has to go through the server renderer: NoSSR only takes its onSSR branch there,
* because under jsdom the layout effect runs and the children render instead. */
const html = renderToString(
<RenderContextProvider
runtime={{ extensions: mockExtensions, getSettings: () => ({}) } as any}
>
<TreePathContextProvider treePath="store.search#category/search-result#category">
<ExtensionPoint treePath="" id="search-title" />
</TreePathContextProvider>
</RenderContextProvider>
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants