From 794a7b33783537c8ddbe1b52610e80d78888b89b Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 11 Aug 2026 18:01:11 +0530 Subject: [PATCH] fix(csr): consume hydration metadata from live DOM --- packages/csr/src/reactive-runtime.ts | 76 +++++++++++++++++++++------- packages/csr/test/reactive.test.ts | 30 ++++++++++- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 969e15da..371ea3d1 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -823,7 +823,12 @@ export const REACTIVE_RUNTIME = String.raw` } function setupScope(el) { - if (el.__wrnexusScope) return; + if (el.__wrnexusScope) { + el.removeAttribute("data-scope"); + el.removeAttribute("data-wrn-scope"); + el.removeAttribute("data-wrn-behavior"); + return; + } el.__wrnexusScope = true; el.__wrnexusHydrated = true; @@ -1417,20 +1422,44 @@ export const REACTIVE_RUNTIME = String.raw` // that received the slot and continuing the search from its mount point. // The loop repeats so slots nested through several components resolve to // the scope that actually wrote the markup. + function isScopeRoot(node) { + return !!( + node && + node.nodeType === 1 && + (node.__wrnexusScope || + node.hasAttribute("data-scope") || + node.hasAttribute("data-wrn-scope")) + ); + } + + function closestScope(node) { + for (var current = node; current; current = current.parentNode) { + if (isScopeRoot(current)) return current; + } + return null; + } + + function closestScopeOrSlot(node) { + for (var current = node; current; current = current.parentNode) { + if ( + current.nodeType === 1 && + (current.hasAttribute("data-wrn-slot") || isScopeRoot(current)) + ) { + return current; + } + } + return null; + } + function ownerScope(node) { var cursor = node.nodeType === 1 ? node : node.parentNode; - for (var guard = 0; cursor && cursor.closest && guard < 32; guard++) { - var found = cursor.closest( - "[data-scope], [data-wrn-scope], [data-wrn-slot]", - ); + for (var guard = 0; cursor && guard < 32; guard++) { + var found = closestScopeOrSlot(cursor); if (!found) return null; if (!found.hasAttribute("data-wrn-slot")) return found; // Step out of the component whose slot this content filled, then keep // looking from just above that component's root. - var componentRoot = - found.parentNode && found.parentNode.closest - ? found.parentNode.closest("[data-scope], [data-wrn-scope]") - : null; + var componentRoot = closestScope(found.parentNode); cursor = componentRoot ? componentRoot.parentNode : found.parentNode; } return null; @@ -1658,6 +1687,7 @@ export const REACTIVE_RUNTIME = String.raw` "data-wrn-bind-", ) === 0 ) { + node.removeAttribute(attribute.name); var binding; try { @@ -2354,6 +2384,7 @@ export const REACTIVE_RUNTIME = String.raw` return; } + node.removeAttribute(marker.name); var binding; try { @@ -2538,8 +2569,7 @@ export const REACTIVE_RUNTIME = String.raw` Array.prototype.slice .call(el.querySelectorAll("[data-wrn-events]")) .forEach(function (node) { - var componentRoot = - node.closest("[data-scope], [data-wrn-scope]"); + var componentRoot = closestScope(node); if (!componentRoot || componentRoot === el) return; // Only the scope that mounted this component owns its outputs. if ( @@ -2615,7 +2645,8 @@ export const REACTIVE_RUNTIME = String.raw` // server forwards these markers onto the rendered child root; evaluate // them here and write changes into the child's prop signals. Array.prototype.slice - .call(el.querySelectorAll("[data-scope], [data-wrn-scope]")) + .call(el.querySelectorAll("*")) + .filter(isScopeRoot) .forEach(function (node) { if (!node.parentNode || ownerScope(node.parentNode) !== el) return; Array.prototype.slice.call(node.attributes).forEach(function (attr) { @@ -2742,16 +2773,25 @@ export const REACTIVE_RUNTIME = String.raw` } renderAll(); + + // Hydration metadata is an SSR transport format, not application DOM. + // All values needed after setup now live in closures, WeakMaps, or private + // element properties, so keep the live DOM limited to actual web markup. + el.removeAttribute("data-scope"); + el.removeAttribute("data-wrn-scope"); + el.removeAttribute("data-wrn-behavior"); } function disposeBehaviors(root) { var elements = []; - if (root && root.nodeType === 1 && root.hasAttribute("data-wrn-behavior")) { - elements.push(root); - } - if (root && root.querySelectorAll) { - Array.prototype.push.apply(elements, root.querySelectorAll("[data-wrn-behavior]")); - } + mountedBehaviorRoots.forEach(function (element) { + if ( + element === root || + (root && root.nodeType === 1 && root.contains && root.contains(element)) + ) { + elements.push(element); + } + }); elements.reverse().forEach(function (element) { var instance = behaviorInstances.get(element); if (instance) instance.dispose(); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 8e52280e..eb245c1c 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -522,6 +522,33 @@ test("template literals work inside browser API call arguments", () => { expect(win.document.querySelector("button")!.textContent).toBe("Saved 3"); }); +test("hydration consumes private scope, behavior, and binding metadata", () => { + const behavior = Buffer.from( + JSON.stringify({ + functions: "function toggle() { active = !active }", + watches: [], + lifecycle: {}, + }), + ).toString("base64"); + const scope = Buffer.from(JSON.stringify({ active: false })).toString("base64"); + const win = mount( + `
` + + `` + + `
`, + ); + const root = win.document.getElementById("scope")!; + const button = win.document.getElementById("toggle")!; + + expect(root.hasAttribute("data-scope")).toBe(false); + expect(root.hasAttribute("data-wrn-scope")).toBe(false); + expect(root.hasAttribute("data-wrn-behavior")).toBe(false); + expect(button.hasAttribute("data-wrn-bind-0")).toBe(false); + expect(button.getAttribute("aria-expanded")).toBe("false"); + + (button as unknown as { click(): void }).click(); + expect(button.getAttribute("aria-expanded")).toBe("true"); +}); + test("compiled client functions are not overwritten by fallback behavior parsing", () => { const behavior = Buffer.from( JSON.stringify({ @@ -817,8 +844,7 @@ test("development runtime accepts component-local and inline wire variables", () win.document.head.innerHTML = ``; - win.document.body.innerHTML = - `
`; + win.document.body.innerHTML = `
`; (globalThis as Record).window = win; (globalThis as Record).document = win.document; (globalThis as Record).location = win.location;