Fix live variant cycling hydration mismatch on SSR frameworks (#287) (#288)

* Fix live variant cycling hydration mismatch on SSR frameworks

Drive variant visibility and range/toggle --p-* custom properties through
an injected session stylesheet instead of mutating hidden/style on
server-rendered variant divs. Fixes flaky nextjs-app-router expectConsoleClean
failures (issue #287), same pattern as scroll-anchor (#276) and pick-cursor (#286).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor variant-state stylesheet for readability

Extract named display constants (VARIANT_HIDE_DECL / VARIANT_SHOW_DECL) and
small variantStateSelector / variantParamDecls helpers so the rule-building is
self-documenting. Restore the scroll-lock comment to startScrollLock. No
behavior change; regression guards updated to match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: keep variant-state stylesheet in sync on first-reveal and paramless cycle

Stop refreshParamsPanel from removing the injected variant-state sheet
during GENERATING first-reveal, and re-sync the sheet when cycling to a
paramless variant so stale --p-* rules do not persist. Harden the
updateVariantStateStylesheet guard to num == null || num < 1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: apply tuned --p-* inline for client-mounted Svelte component variants

Svelte component sessions mount into [data-impeccable-component-mount]
with no [data-impeccable-variant="N"] wrapper for the state stylesheet to
target. Restore inline --p-* on the client-mounted element for range/toggle
params while keeping the SSR div path on the injected stylesheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-07-07 17:25:22 -07:00
committed by GitHub
co-authored by Cursor
parent 1d8f051454
commit 49ae0384b9
3 changed files with 146 additions and 26 deletions
+84 -23
View File
@@ -153,6 +153,7 @@
let scrollLockRaf = null;
let scrollLockAbort = null;
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
// saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -3035,16 +3036,26 @@
function applyParamValue(variantEl, param, value) {
if (!variantEl) return;
const attr = 'data-p-' + param.id;
if (param.kind === 'range') {
variantEl.style.setProperty('--p-' + param.id, String(value));
} else if (param.kind === 'toggle') {
if (param.kind === 'toggle') {
const on = !!value;
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
if (on) variantEl.setAttribute(attr, 'on');
else variantEl.removeAttribute(attr);
} else if (param.kind === 'steps') {
variantEl.setAttribute(attr, String(value));
}
// Svelte component variants are client-mounted into
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
// wrapper for the state stylesheet to target, and the element is not SSR'd,
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
// on the mounted element so scoped preview CSS resolves them.
if (svelteComponentSession?.sessionId === currentSessionId) {
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
return;
}
// range/toggle --p-* custom properties are driven through the injected
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
updateVariantStateStylesheet(currentSessionId, visibleVariant);
}
function applyParamDefaults(variantEl, params) {
@@ -4714,6 +4725,7 @@
paramsCurrentValues = {};
tuneOpen = false;
hideParamsPanel();
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
return;
}
applyParamDefaults(variantEl, params);
@@ -4771,20 +4783,7 @@
function isVariantShown(el) {
if (!el) return false;
if (el.hidden) return false;
if (el.style?.display === 'none') return false;
return true;
}
function setVariantShown(el, shown) {
if (!el) return;
if (shown) {
el.removeAttribute('hidden');
el.style.display = '';
} else {
el.setAttribute('hidden', '');
el.style.display = 'none';
}
return getComputedStyle(el).display !== 'none';
}
function scheduleCyclingBarSync(sessionId, variantNum) {
@@ -4823,11 +4822,7 @@
}
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
for (const child of wrapper.children) {
const v = child.dataset ? child.dataset.impeccableVariant : null;
if (!v) continue;
setVariantShown(child, v === String(num));
}
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
// CYCLING yet, the subsequent CYCLING transition triggers its own
// refresh) and every cycle step.
@@ -5492,6 +5487,7 @@
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearSession();
clearHandled();
resetSessionFileMeta();
@@ -5806,6 +5802,68 @@
return variantDiv;
}
// Variant visibility and range/toggle params are expressed through ONE
// injected stylesheet, never inline attributes on the variant divs. Those
// divs are scaffolded into page source, so SSR frameworks (Next.js App
// Router) server-render them; toggling their `hidden` / inline `style` /
// `--p-*` client-side trips a React 19 hydration mismatch on the next
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
// computed effect without mutating any hydrated element's attributes.
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
const VARIANT_HIDE_DECL = 'display: none !important;';
const VARIANT_SHOW_DECL = 'display: block !important;';
// Build a direct-child variant selector for a session. With `num`, targets a
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
// every variant via the bare `[data-impeccable-variant]` attribute.
function variantStateSelector(sessionId, num) {
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
const variant = num == null
? '[data-impeccable-variant]'
: '[data-impeccable-variant="' + num + '"]';
return wrapper + ' > ' + variant;
}
// Serialize the visible variant's knob values into `--p-<id>` custom-property
// declarations. Only range (number) and toggle (boolean) values become a
// custom property; steps params drive `data-p-*` attributes instead.
function variantParamDecls(values) {
return Object.entries(values || {})
.map(([id, val]) => {
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
return '';
})
.join('');
}
function updateVariantStateStylesheet(sessionId, num) {
if (!sessionId || num == null || num < 1) return;
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = VARIANT_STATE_STYLE_ID;
(document.head || document.documentElement).appendChild(styleEl);
}
// Hide every variant except the visible one (incl. the SSR'd "original").
const hideOthers = variantStateSelector(sessionId)
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
// Force-show the visible variant (beats the source inline display:none on
// v2/v3) and apply its knob values as custom properties.
const showVisible = variantStateSelector(sessionId, num)
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
}
function removeVariantStateStylesheet() {
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
}
// Hold window.scrollY at a fixed value across DOM mutations inside the
// session's wrapper (HMR patches, variant inserts, cycle swaps).
function startScrollLock(sessionId, initialTargetY) {
@@ -7636,6 +7694,7 @@ void main() {
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
clearSession();
resetSessionFileMeta();
@@ -7897,6 +7956,7 @@ void main() {
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
finalizeInsertSession();
clearSession();
@@ -9989,6 +10049,7 @@ void main() {
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
setLiveState('IDLE');
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
removeVariantStateStylesheet();
window.__IMPECCABLE_LIVE_INIT__ = false;
console.log('[impeccable] Live mode exited.');
}
+61 -2
View File
@@ -318,6 +318,65 @@ describe('live-browser.js regression guards', () => {
);
});
it('drives variant visibility and --p-* params through a stylesheet, not inline SSR element mutation', () => {
// Variant divs live in page source, so Next.js App Router server-renders
// them. Toggling hidden/style.display/--p-* on those nodes client-side
// makes React 19 report a hydration mismatch on the next Fast-Refresh
// re-render — the same failure mode as scroll-anchor (#276) and pick-cursor
// (#286). Visibility and range/toggle custom properties must go through an
// injected <style> rule instead.
assert.doesNotMatch(
SOURCE,
/function setVariantShown\(/,
'event=live_browser.variant_visibility_hydration actor=browser operation=show_variant_in_dom risk=react19_hydration_mismatch_on_next_app_router expected=stylesheet_rule actual=hidden_and_inline_display_on_variant_div',
);
assert.match(
SOURCE,
/if \(svelteComponentSession\?\.sessionId === currentSessionId\)[\s\S]{0,280}?variantEl\.style\.setProperty\('--p-'/,
'client-mounted Svelte component variants drive --p-* inline (no SSR div, no hydration), unlike server-rendered variant divs',
);
assert.match(
SOURCE,
/function applyParamValue\([\s\S]{0,1200}?svelteComponentSession\?\.sessionId === currentSessionId[\s\S]{0,400}?return;[\s\S]{0,400}?updateVariantStateStylesheet\(currentSessionId, visibleVariant\)/,
'applyParamValue must short-circuit the Svelte inline path before the SSR stylesheet path',
);
assert.match(
SOURCE,
/const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';/,
'the variant-state style needs a stable id constant so it can be created and removed by id',
);
assert.match(
SOURCE,
/const VARIANT_HIDE_DECL = 'display: none !important;';/,
'the hidden-variant rule should be a named constant for readability',
);
assert.match(
SOURCE,
/function updateVariantStateStylesheet\(sessionId, num\)[\s\S]{0,500}?createElement\('style'\)[\s\S]{0,500}?VARIANT_HIDE_DECL/,
'variant cycling must hide non-visible variants with an injected <style> rule keyed by VARIANT_STATE_STYLE_ID',
);
assert.match(
SOURCE,
/function removeVariantStateStylesheet\(\)[\s\S]{0,120}?document\.getElementById\(VARIANT_STATE_STYLE_ID\)\?\.remove\(\)/,
'leaving CYCLING or tearing down live mode must remove the injected variant-state <style>',
);
assert.doesNotMatch(
SOURCE,
/function refreshParamsPanel\(\)[\s\S]{0,220}?if \(state !== 'CYCLING'\)[\s\S]{0,220}?removeVariantStateStylesheet\(\)/,
'refreshParamsPanel must not strip the variant-state sheet during GENERATING first-reveal',
);
assert.match(
SOURCE,
/function refreshParamsPanel\(\)[\s\S]{0,600}?if \(!variantEl \|\| params\.length === 0\)[\s\S]{0,220}?updateVariantStateStylesheet\(currentSessionId, visibleVariant\)/,
'paramless variant switches must re-sync the variant-state sheet to clear stale --p-* params',
);
assert.match(
SOURCE,
/function isVariantShown\(el\)[\s\S]{0,120}?getComputedStyle\(el\)\.display/,
'visible-variant detection must read computed display, not el.hidden or el.style.display',
);
});
it('global bar includes expandable page chat affordance', () => {
assert.match(
SOURCE,
@@ -660,8 +719,8 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/function setVariantShown\(el, shown\)[\s\S]{0,200}?removeAttribute\('hidden'\)/,
'variant cycling must clear the hidden attribute, not only style.display',
/function updateVariantStateStylesheet\(sessionId, num\)[\s\S]{0,900}?VARIANT_HIDE_DECL/,
'variant cycling must hide non-visible variants via injected stylesheet, not hidden/style.display on SSR divs',
);
assert.match(
SOURCE,
+1 -1
View File
@@ -699,7 +699,7 @@ export async function getVisibleVariant(page) {
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
if (wrapper) {
const variants = [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')];
const visible = variants.find((variant) => variant.style.display !== 'none');
const visible = variants.find((variant) => getComputedStyle(variant).display !== 'none');
const idx = visible ? parseInt(visible.dataset.impeccableVariant || '0', 10) : 0;
if (idx > 0) return idx;
}