mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
* 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:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user