mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
Fix React hydration mismatch from live pick-cursor class on SSR roots (#286)
* Fix React hydration mismatch from live pick-cursor class on SSR roots Entering pick mode toggled a `impeccable-live-pick-cursor` class on `document.documentElement` (and the insert-axis cursor wrote an inline `style.cursor` on it). `<html>`/`<body>` are server-rendered by frameworks like Next.js App Router, so a client-only attribute the server HTML never emitted makes React 19 log "a tree hydrated but some attributes of the server rendered HTML didn't match" on the next Fast-Refresh re-render. It surfaced as a console.error that flaked the nextjs-app-router live-e2e fixture's expectConsoleClean probe. This is the same root-cause class as the scroll-anchor lock fixed in #276 (client mutation of a hydrated SSR root), but a separate offender that fix did not cover. Apply the same shape: drive the pick / insert cursor entirely through the textContent of one injected `<style>` keyed by PICK_CURSOR_STYLE_ID, never by a class or inline style on `<html>`. Same computed effect (global `cursor` rule, reverted inside the overlay chrome), recreated on activation and removed on teardown. Regression guard updated to pin the new shape: no `document.documentElement.classList.*` mutation anywhere in the overlay, the cursor applied through the injected style, and the style removed by id on exit. Verified end-to-end: the nextjs-app-router live-e2e fixture now passes the full click -> Go -> cycle -> accept cycle with a clean console. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Remove now-dead pageInteractionCursorActive flag The flag's only reader was the old inline-style cleanup branch in syncPageInteractionCursor, which the stylesheet refactor removed. It is now write-only, so drop the declaration and both writes (Greptile review). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
55d11fb2ad
commit
8eedb150c5
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -1916,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9983,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -559,15 +559,37 @@ describe('live-browser.js regression guards', () => {
|
||||
/function syncPageInteractionCursor\(\)[\s\S]{0,420}?cursorForInsertAxis/,
|
||||
'insert picking cursor follows row/column axis',
|
||||
);
|
||||
assert.match(
|
||||
// The pick / insert cursor must be driven by an injected <style>, never by a
|
||||
// class or inline style on <html>. <html>/<body> are server-rendered by
|
||||
// frameworks like Next.js App Router, so a client-only attribute the server
|
||||
// HTML never emitted trips React 19's "a tree hydrated but some attributes
|
||||
// ... didn't match" on the next Fast-Refresh re-render — surfacing as a
|
||||
// console.error that fails the nextjs-app-router live-e2e expectConsoleClean
|
||||
// probe. Same fix shape as the scroll-anchor lock above.
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/function ensurePickCursorStyle\(\)[\s\S]{0,420}?cursor: crosshair !important/,
|
||||
'pick mode injects a crosshair cursor that wins over page pointer styles',
|
||||
/document\.documentElement\.classList\.(?:add|remove|toggle)\(/,
|
||||
'event=live_browser.pick_cursor_hydration actor=browser operation=sync_page_interaction_cursor risk=react19_hydration_mismatch_on_next_app_router expected=stylesheet_rule actual=class_on_html',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/document\.documentElement\.classList\.add\(PICK_CURSOR_CLASS\)/,
|
||||
'pick mode toggles a document-level class for the crosshair cursor',
|
||||
/const PICK_CURSOR_STYLE_ID = PREFIX \+ '-pick-cursor-style';/,
|
||||
'the pick-cursor style needs a stable id constant so it can be created and removed by id',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function setPageInteractionCursor\(cursor\)[\s\S]{0,700}?cursor: ' \+ cursor \+ ' !important/,
|
||||
'pick / insert cursor is applied through the injected <style> textContent, keyed by PICK_CURSOR_STYLE_ID',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/cursor = 'crosshair'/,
|
||||
'pick mode uses a crosshair cursor that wins over page pointer styles',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/document\.getElementById\(PICK_CURSOR_STYLE_ID\)\?\.remove\(\)/,
|
||||
'exiting live mode removes the injected pick-cursor <style> so it never outlives the session',
|
||||
);
|
||||
assert.match(SOURCE, /function hitSiblingInsertGap\(/, 'insert mode detects gaps between siblings');
|
||||
assert.match(SOURCE, /function resolveInsertHover\(/, 'insert hover resolves axis-aware boundaries');
|
||||
|
||||
Reference in New Issue
Block a user