made tiny updates

This commit is contained in:
SinachPat
2026-05-03 20:34:18 +01:00
parent 4dca0f1bac
commit 3f029e15c2
30 changed files with 3463 additions and 357 deletions
+289 -36
View File
@@ -138,6 +138,22 @@ export function buildProxyFiberHookScript(): string {
var selectedNodeId = null; // currently highlighted component
var highlightEl = null; // the blue-ring DOM overlay element
// ── Style override persistence (H-7 fix) ─────────────────────────────────
// Inline styles set by PATCH_ELEMENT_STYLE are wiped by React on the next
// reconciliation of the patched component. To make patches survive, we
// mirror them into an external stylesheet keyed by a data-om-id attribute
// that we re-tag onto the DOM on every commit.
//
// styleOverrides[nodeId][property] = value
// childrenStyleOverrides[parentId][selector][property] = value
//
// After each commit we (a) rewrite data-om-id on every nodeMap element so
// the selectors still match, and (b) rebuild the <style> sheet text.
// Cascade wins over component styles because every rule is !important.
var styleOverrides = {}; // nodeId -> { property -> value }
var childrenStyleOverrides = {}; // parentNodeId -> { selector -> { property -> value } }
var overrideStyleEl = null; // the <style id="__om_overrides__"> element
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg) {
try {
@@ -172,6 +188,10 @@ export function buildProxyFiberHookScript(): string {
fiberMap = new WeakMap();
var tree = serializeFiber(root.current, '');
// H-7 fix: re-tag DOM with data-om-id and reapply the override sheet
// BEFORE telling the host the tree updated, so the visual is consistent
// by the time the host repaints its inspector.
reapplyOverrides();
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (layout may shift).
if (selectedNodeId) updateHighlight();
@@ -183,12 +203,19 @@ export function buildProxyFiberHookScript(): string {
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
//
// siblingIndex is the position among VISIBLE (named) siblings at the same
// flattened level — NOT fiber.index. Using fiber.index caused H-2: two
// same-named components living under different transparent wrappers
// (Fragment, Context.Provider, React.memo) both had fiber.index === 0
// relative to their own React parent, so when collectChildren flattened
// them into the same visible level they collided to the same nodeId.
//
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
// are transparent — their children are collected directly into their parent's
// children array. Returning only the first named child (the old approach)
// caused entire subtrees to vanish from the tree.
function serializeFiber(fiber, parentId) {
function serializeFiber(fiber, parentId, siblingIndex) {
if (!fiber) return null;
var name = getDisplayName(fiber);
if (!name) {
@@ -202,11 +229,27 @@ export function buildProxyFiberHookScript(): string {
return { id: '__root__', name: '__root__', props: {}, children: children };
}
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(siblingIndex || 0);
var rect = getDomRect(fiber);
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
if (rect) node.domRect = rect;
// ── _debugSource extraction (Phase 1) ────────────────────────────────────
// _debugSource is set by React's dev build on every fiber created from JSX.
// It points to the JSX call site (the file that wrote <ComponentName />),
// NOT the definition file. Only present when __DEV__ === true.
// We only read it for function/class components (typeof fiber.type === 'function')
// to avoid bogus call-site data on host elements (div, span, etc.).
try {
if (fiber._debugSource && typeof fiber.type === 'function') {
var ds = fiber._debugSource;
if (ds.fileName && typeof ds.lineNumber === 'number') {
node.callSite = { fileName: ds.fileName, lineNumber: ds.lineNumber };
if (typeof ds.columnNumber === 'number') node.callSite.columnNumber = ds.columnNumber;
}
}
} catch (e) { /* _debugSource access can throw in some SSR contexts */ }
// Register in both maps for O(1) lookup.
nodeMap[nodeId] = { domRect: rect || null, fiber: fiber };
fiberMap.set(fiber, nodeId);
@@ -230,18 +273,28 @@ export function buildProxyFiberHookScript(): string {
// Collect all NAMED descendants of fiber.child into out[], transparently
// flattening unnamed intermediates (Fragments, Providers, wrappers).
function collectChildren(fiber, parentId, out) {
//
// The sibling counter (counterRef.n) is per-VISIBLE-LEVEL, so it survives
// the recursion into transparent wrappers — that's how H-2 (nodeId
// collision across same-named siblings under different wrappers) is
// prevented. Pass the same counterRef to recursive calls.
function collectChildren(fiber, parentId, out, counterRef) {
if (!counterRef) counterRef = { n: 0 };
var child = fiber.child;
while (child) {
var name = getDisplayName(child);
if (name) {
var serialized = serializeFiber(child, parentId);
if (serialized) out.push(serialized);
var serialized = serializeFiber(child, parentId, counterRef.n);
if (serialized) {
out.push(serialized);
counterRef.n += 1;
}
} else {
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
// flatten its children directly into our level — they share parentId.
// flatten its children directly into our level — they share parentId
// AND the visible sibling counter, so a Fragment never resets indices.
fiberMap.set(child, null); // mark as non-selectable
collectChildren(child, parentId, out);
collectChildren(child, parentId, out, counterRef);
}
child = child.sibling;
}
@@ -267,6 +320,142 @@ export function buildProxyFiberHookScript(): string {
return null;
}
// ── Style override sheet (H-7 fix) ───────────────────────────────────────
// CSS attribute selector values are quoted, so "/" and ":" inside the
// nodeId need no escaping. We do escape backslash and double-quote
// defensively in case future ID schemes introduce them.
function escapeAttrValue(s) {
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// CSS property names from the inspector are already lowercase kebab-case.
// Block anything that isn't a CSS-safe identifier so a malicious patch
// payload can't inject rule terminators or other declarations.
function isSafeCssProp(p) {
return typeof p === 'string' && /^-?[a-z][a-z0-9-]*$/.test(p);
}
// Block stylesheet-breaking characters in the value: braces, semicolons,
// angle brackets, backslashes, line breaks, and CSS comment markers. Inline
// setProperty (used for the immediate paint) is already safe — this is
// strictly to keep the override stylesheet text well-formed.
function isSafeCssValue(v) {
if (typeof v !== 'string' || v.length > 500) return false;
if (/[{};<>\\\n\r]/.test(v)) return false;
if (v.indexOf('/*') !== -1 || v.indexOf('*/') !== -1) return false;
return true;
}
function ensureOverrideStyleEl() {
if (overrideStyleEl && overrideStyleEl.parentNode) return overrideStyleEl;
var el = document.getElementById('__om_overrides__');
if (!el) {
el = document.createElement('style');
el.id = '__om_overrides__';
el.setAttribute('data-originmain', 'overrides');
// Append to <head> so it lives past component remounts.
(document.head || document.documentElement).appendChild(el);
}
overrideStyleEl = el;
return el;
}
function tagDomWithIds() {
for (var id in nodeMap) {
if (!Object.prototype.hasOwnProperty.call(nodeMap, id)) continue;
var info = nodeMap[id];
if (!info) continue;
var el = findDomElement(info.fiber);
if (el && typeof el.setAttribute === 'function') {
// Idempotent: only write if the attribute is missing or stale.
if (el.getAttribute('data-om-id') !== id) el.setAttribute('data-om-id', id);
}
}
}
function buildOverrideCss() {
var parts = [];
for (var id in styleOverrides) {
if (!Object.prototype.hasOwnProperty.call(styleOverrides, id)) continue;
var props = styleOverrides[id];
var decls = [];
for (var p in props) {
if (!Object.prototype.hasOwnProperty.call(props, p)) continue;
if (!isSafeCssProp(p) || !isSafeCssValue(props[p])) continue;
decls.push(p + ':' + props[p] + ' !important');
}
if (decls.length) {
parts.push('[data-om-id="' + escapeAttrValue(id) + '"]{' + decls.join(';') + '}');
}
}
for (var pid in childrenStyleOverrides) {
if (!Object.prototype.hasOwnProperty.call(childrenStyleOverrides, pid)) continue;
var bySel = childrenStyleOverrides[pid];
for (var sel in bySel) {
if (!Object.prototype.hasOwnProperty.call(bySel, sel)) continue;
if (!/^[a-z][a-z0-9-]*$/i.test(sel)) continue;
var cprops = bySel[sel];
var cdecls = [];
for (var cp in cprops) {
if (!Object.prototype.hasOwnProperty.call(cprops, cp)) continue;
if (!isSafeCssProp(cp) || !isSafeCssValue(cprops[cp])) continue;
cdecls.push(cp + ':' + cprops[cp] + ' !important');
}
if (cdecls.length) {
parts.push(
'[data-om-id="' + escapeAttrValue(pid) + '"]>' + sel +
'{' + cdecls.join(';') + '}'
);
}
}
}
return parts.join('\n');
}
function reapplyOverrides() {
var hasAny = false;
for (var k in styleOverrides) { hasAny = true; break; }
if (!hasAny) {
for (var k2 in childrenStyleOverrides) { hasAny = true; break; }
}
// Cheap exit only when nothing has ever been recorded AND no sheet exists.
// If the sheet exists with stale rules (e.g. user just cleared the last
// override), we must run through to write the empty CSS back.
if (!hasAny && !overrideStyleEl) return;
if (hasAny) tagDomWithIds();
var el = ensureOverrideStyleEl();
var css = hasAny ? buildOverrideCss() : '';
if (el.textContent !== css) el.textContent = css;
}
function recordStyleOverride(nodeId, property, value) {
if (!isSafeCssProp(property)) return;
var bag = styleOverrides[nodeId];
if (!bag) bag = styleOverrides[nodeId] = {};
if (value === '' || value == null) {
delete bag[property];
var hasAnyProp = false;
for (var p in bag) { hasAnyProp = true; break; }
if (!hasAnyProp) delete styleOverrides[nodeId];
} else if (isSafeCssValue(String(value))) {
bag[property] = String(value);
}
}
function recordChildrenStyleOverride(parentId, selector, property, value) {
if (!/^[a-z][a-z0-9-]*$/i.test(selector)) return;
if (!isSafeCssProp(property)) return;
var bySel = childrenStyleOverrides[parentId];
if (!bySel) bySel = childrenStyleOverrides[parentId] = {};
var bag = bySel[selector];
if (!bag) bag = bySel[selector] = {};
if (value === '' || value == null) {
delete bag[property];
} else if (isSafeCssValue(String(value))) {
bag[property] = String(value);
}
}
function serializeProps(props) {
if (!props || typeof props !== 'object') return {};
var out = {};
@@ -415,26 +604,85 @@ export function buildProxyFiberHookScript(): string {
var styleProps = [
'width','height','background-color','color','font-size',
'font-family','font-weight','line-height','letter-spacing','text-align',
'display','flex-direction','gap','align-items','justify-content',
'display','flex-direction','flex-wrap','gap','row-gap','column-gap',
'align-items','align-content','justify-content','justify-items',
'padding-top','padding-right','padding-bottom','padding-left',
'margin-top','margin-right','margin-bottom','margin-left',
'border-radius','border-width','border-color','border-style',
'opacity','box-shadow','overflow','position',
'border-radius','border-top-left-radius','border-top-right-radius',
'border-bottom-right-radius','border-bottom-left-radius',
'border-width','border-color','border-style',
'opacity','box-shadow','filter','backdrop-filter',
'overflow','position','left','top','right','bottom',
'transform','text-decoration','text-transform',
'grid-template-columns','grid-template-rows',
];
var styles = {};
styleProps.forEach(function(p) { styles[p] = cs.getPropertyValue(p); });
post({ type: 'ELEMENT_STYLES', nodeId: msg.nodeId, styles: styles });
// ── Structural flags for Typography section (Phase 2) ──────────
// hasDirectText: does the element have a direct text node with content?
var hasDirectText = false;
var nodes = rEl.childNodes;
for (var ci = 0; ci < nodes.length; ci++) {
if (nodes[ci].nodeType === 3 && nodes[ci].textContent.trim().length > 0) {
hasDirectText = true;
break;
}
}
// hasParagraphChildren: does the element have at least one direct <p> child?
var hasParagraphChildren = rEl.querySelector(':scope > p') !== null;
post({
type: 'ELEMENT_STYLES',
nodeId: msg.nodeId,
styles: styles,
hasDirectText: hasDirectText,
hasParagraphChildren: hasParagraphChildren,
});
}
}
}
break;
case 'PATCH_ELEMENT_STYLE':
if (typeof msg.nodeId === 'string' && typeof msg.property === 'string') {
// C-4 fix: (msg.value || '') is falsy for 0, which removes the property.
// Use explicit null check so zero values are applied correctly.
var pVal = msg.value != null ? String(msg.value) : '';
// H-7 fix: record into the override stylesheet FIRST so the patch
// survives the next React reconciliation. Then also apply inline
// for an immediate, no-flicker visual update on this paint.
recordStyleOverride(msg.nodeId, msg.property, pVal);
var pInfo = nodeMap[msg.nodeId];
if (pInfo) {
var pEl = findDomElement(pInfo.fiber);
if (pEl) pEl.style.setProperty(msg.property, String(msg.value || ''));
if (pEl) pEl.style.setProperty(msg.property, pVal);
}
reapplyOverrides();
}
break;
case 'PATCH_CHILDREN_STYLE':
// Apply a CSS property to all matching direct children of a node.
// Used for paragraph-spacing: patches margin-bottom on each direct <p> child.
if (typeof msg.parentNodeId === 'string' && typeof msg.property === 'string') {
if (typeof msg.selector !== 'string') break;
// C-5 security fix: validate selector against a simple element-name
// allowlist before passing to querySelectorAll. An unconstrained
// msg.selector could be crafted to inject CSS or throw a DOMException.
if (!/^[a-z][a-z0-9-]*$/i.test(msg.selector)) break;
var pcVal = msg.value != null ? String(msg.value) : '';
// H-7 fix: persist children-style patches the same way.
recordChildrenStyleOverride(msg.parentNodeId, msg.selector, msg.property, pcVal);
var pcInfo = nodeMap[msg.parentNodeId];
if (pcInfo) {
var pcEl = findDomElement(pcInfo.fiber);
if (pcEl) {
var children = pcEl.querySelectorAll(':scope > ' + msg.selector);
for (var ci2 = 0; ci2 < children.length; ci2++) {
children[ci2].style.setProperty(msg.property, pcVal);
}
}
}
reapplyOverrides();
}
break;
case 'REMOVE_ELEMENT':
@@ -500,8 +748,9 @@ export function buildProxyFiberHookScript(): string {
if (!path || typeof path !== 'string') return;
// Skip hash fragments, external links that slipped through, and dynamic segments
if (path.charAt(0) !== '/') return;
// Normalise: strip trailing slash except for root
var norm = path.length > 1 ? path.replace(/\\/+$/, '') : '/';
// M-4 fix: the original regex /\\/+$/ matched a literal backslash, not a
// forward slash. Correct regex is /\/+$/.
var norm = path.length > 1 ? path.replace(/\/+$/, '') : '/';
if (seen[norm]) return;
seen[norm] = true;
var label = (hint && typeof hint === 'string' && hint.trim().slice(0, 50)) || humanLabel(norm);
@@ -512,29 +761,33 @@ export function buildProxyFiberHookScript(): string {
addRoute(window.location.pathname, document.title || undefined);
// ② Next.js Pages Router — __NEXT_DATA__ contains the full page list in build id manifest
try {
var nextData = window.__NEXT_DATA__;
if (nextData && nextData.buildId) {
// Fetch the pages manifest — available at /_next/static/{buildId}/_buildManifest.js
var manifestUrl = '/_next/static/' + nextData.buildId + '/_buildManifest.js';
var xhr = new XMLHttpRequest();
xhr.open('GET', manifestUrl, false); // sync — runs at init time before user interaction
xhr.send();
if (xhr.status === 200) {
// Manifest exposes self.__BUILD_MANIFEST = { sortedPages: [...] }
var match = xhr.responseText.match(/sortedPages\\s*:\\s*(\\[[^\\]]+\\])/);
if (match) {
try {
var pages = JSON.parse(match[1]);
pages.forEach(function(p) {
// Skip catch-all and dynamic segments for now; static routes only
if (p.indexOf('[') === -1) addRoute(p);
});
} catch (e) { /* parse failed */ }
}
// H-1 fix: replaced synchronous XHR (deprecated, blocks main thread) with
// async fetch. Route discovery happens in a setTimeout callback so async is safe.
(function() {
try {
var nextData = window.__NEXT_DATA__;
if (nextData && nextData.buildId) {
var manifestUrl = '/_next/static/' + nextData.buildId + '/_buildManifest.js';
fetch(manifestUrl).then(function(res) {
if (!res.ok) return;
return res.text();
}).then(function(text) {
if (!text) return;
var match = text.match(/sortedPages\s*:\s*(\[[^\]]+\])/);
if (match) {
try {
var pages = JSON.parse(match[1]);
pages.forEach(function(p) {
if (p.indexOf('[') === -1) addRoute(p);
});
// Re-post now that we have more routes from the manifest.
post({ type: 'ROUTES_DISCOVERED', routes: routes.slice() });
} catch (e) { /* parse failed */ }
}
}).catch(function() { /* manifest unavailable */ });
}
}
} catch (e) { /* Next.js not present or manifest unavailable */ }
} catch (e) { /* Next.js not present */ }
})();
// ③ <a href> same-origin links from the rendered DOM
var anchors = document.querySelectorAll('a[href]');
+22 -2
View File
@@ -13,6 +13,14 @@ export interface FiberNode {
props: Record<string, unknown>;
children: FiberNode[];
domRect?: DOMRectLike;
/** JSX call site: the file + line where <ComponentName /> was written (not its definition).
* Only present in React dev builds (__DEV__ = true). Absent in production.
* Renamed from `sourceFile` to `callSite` for accuracy — see Phase 1 §4.2. */
callSite?: {
fileName: string;
lineNumber: number;
columnNumber?: number;
};
}
export interface DOMRectLike {
@@ -34,6 +42,9 @@ export type HostMessage =
/** Apply a single CSS property override directly to the component's DOM element.
* Non-destructive — sets inline style only; source files are unchanged. */
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
/** Apply a CSS property to all matching direct children of a node.
* Used for paragraph-spacing: patches margin-bottom on each direct <p> child. */
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
/** Hide a component's DOM element (sets display:none). Non-destructive. */
| { type: 'REMOVE_ELEMENT'; nodeId: string };
@@ -51,8 +62,17 @@ export type RendererMessage =
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
| { type: 'COMPONENT_DESELECTED' }
| { type: 'ERROR'; message: string }
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node.
* Also includes structural flags used by the Typography section of the Design Panel. */
| {
type: 'ELEMENT_STYLES';
nodeId: string;
styles: Record<string, string>;
/** true if the DOM element has a direct TEXT_NODE child with non-whitespace content */
hasDirectText: boolean;
/** true if the DOM element has at least one direct <p> child */
hasParagraphChildren: boolean;
}
/** All discoverable routes found in the running app — sent once after READY
* and again after each SPA navigation. */
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };