mnfst-render 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/manifest.render.mjs +145 -27
  2. package/package.json +1 -1
@@ -1883,6 +1883,36 @@ async function runPrerender(config) {
1883
1883
  let nextId = 0;
1884
1884
  const skipTags = new Set(['MAIN', 'BODY', 'HTML']);
1885
1885
 
1886
+ // Own MutationObserver registered before any other script on the page.
1887
+ // This guarantees we process DOM additions before Alpine's observer does —
1888
+ // critically, before Alpine's observer calls initTree on newly expanded
1889
+ // Manifest components (preloaded or lazy) and bakes their `:class` state.
1890
+ const installHydrateObserver = () => {
1891
+ if (window.__manifestHydrateObserver || !document.body) return;
1892
+ const obs = new MutationObserver((mutations) => {
1893
+ for (const m of mutations) {
1894
+ if (m.type !== 'childList') continue;
1895
+ for (const node of m.addedNodes) {
1896
+ if (node.nodeType !== 1) continue;
1897
+ try { snapshotSubtree(node); } catch (_) {}
1898
+ }
1899
+ }
1900
+ });
1901
+ obs.observe(document.body, { childList: true, subtree: true });
1902
+ window.__manifestHydrateObserver = obs;
1903
+ };
1904
+ if (typeof document !== 'undefined') {
1905
+ if (document.body) {
1906
+ installHydrateObserver();
1907
+ } else {
1908
+ document.addEventListener('DOMContentLoaded', installHydrateObserver, { once: true });
1909
+ // Also try once readyState flips to interactive
1910
+ document.addEventListener('readystatechange', () => {
1911
+ if (document.readyState !== 'loading') installHydrateObserver();
1912
+ });
1913
+ }
1914
+ }
1915
+
1886
1916
  const snapshotElement = (el) => {
1887
1917
  if (!el || el.nodeType !== 1) return;
1888
1918
  if (el.hasAttribute('data-manifest-hyd-id')) return; // already snapshotted
@@ -1894,7 +1924,7 @@ async function runPrerender(config) {
1894
1924
  if (a.name === 'data-manifest-hyd-id') continue;
1895
1925
  attrs[a.name] = a.value;
1896
1926
  }
1897
- allSnapshots.push({ id, attrs });
1927
+ allSnapshots.push({ id, tag: el.tagName, attrs });
1898
1928
  };
1899
1929
 
1900
1930
  const snapshotElementAndDescendants = (el) => {
@@ -1943,23 +1973,52 @@ async function runPrerender(config) {
1943
1973
  window.__manifestHydrateSnapshots = allSnapshots;
1944
1974
  };
1945
1975
 
1946
- // Wrap Alpine.initTree once Alpine is available. alpine:init fires before
1947
- // Alpine walks the tree for the first time, giving us the perfect insertion
1948
- // point. After wrapping, every initTree call (initial walk + every lazy
1949
- // component load) snapshots its subtree before Alpine processes it.
1950
- let installed = false;
1951
- const installInterceptor = () => {
1952
- if (installed || !window.Alpine || typeof window.Alpine.initTree !== 'function') return;
1953
- installed = true;
1954
- const original = window.Alpine.initTree.bind(window.Alpine);
1955
- window.Alpine.initTree = function (root) {
1956
- try { snapshotSubtree(root || document.body); } catch (_) { /* graceful */ }
1957
- return original.apply(this, arguments);
1958
- };
1976
+ // Wrap Alpine.start so the snapshot runs INSIDE the start call, before
1977
+ // Alpine has a chance to walk and mutate the tree. alpine:init as an
1978
+ // external hook proved unreliable in some configurations it fires after
1979
+ // Alpine has already processed some elements. Alpine.start is the single
1980
+ // synchronous entry point for the initial walk, so wrapping it guarantees
1981
+ // we capture source state before any directive has been applied.
1982
+ //
1983
+ // We also wrap Alpine.initTree for lazy-loaded components that appear in
1984
+ // the DOM after Alpine.start() has completed (fetched by the components
1985
+ // plugin in response to new <x-*> placeholders).
1986
+ //
1987
+ // Both wraps are installed via a defineProperty setter on window.Alpine
1988
+ // so they land the instant Alpine's CDN script does `window.Alpine = ...`.
1989
+ const wrap = (alpine) => {
1990
+ if (!alpine || alpine.__manifestRenderWrapped) return;
1991
+ alpine.__manifestRenderWrapped = true;
1992
+ if (typeof alpine.start === 'function') {
1993
+ const originalStart = alpine.start.bind(alpine);
1994
+ alpine.start = function () {
1995
+ try { snapshotSubtree(document.body); } catch (_) { /* graceful */ }
1996
+ return originalStart.apply(this, arguments);
1997
+ };
1998
+ }
1999
+ if (typeof alpine.initTree === 'function') {
2000
+ const originalInit = alpine.initTree.bind(alpine);
2001
+ alpine.initTree = function (root) {
2002
+ try { snapshotSubtree(root || document.body); } catch (_) { /* graceful */ }
2003
+ return originalInit.apply(this, arguments);
2004
+ };
2005
+ }
1959
2006
  };
2007
+
2008
+ let _Alpine;
2009
+ try {
2010
+ Object.defineProperty(window, 'Alpine', {
2011
+ configurable: true,
2012
+ enumerable: true,
2013
+ get() { return _Alpine; },
2014
+ set(v) { _Alpine = v; wrap(v); },
2015
+ });
2016
+ } catch (_) { /* defineProperty failed, fall back to event listeners */ }
2017
+
1960
2018
  if (typeof document !== 'undefined') {
1961
- document.addEventListener('alpine:init', installInterceptor);
1962
- document.addEventListener('alpine:initialized', installInterceptor);
2019
+ // Event-based fallback in case the setter trap missed Alpine assignment.
2020
+ document.addEventListener('alpine:init', () => wrap(window.Alpine));
2021
+ document.addEventListener('alpine:initialized', () => wrap(window.Alpine));
1963
2022
  }
1964
2023
  });
1965
2024
 
@@ -2202,22 +2261,81 @@ async function runPrerender(config) {
2202
2261
  // it would in the live SPA. After restoring source attributes we re-add the
2203
2262
  // `data-prerender-hydrate` marker so downstream Node.js stripping passes
2204
2263
  // continue to skip these elements.
2205
- await page.evaluate(() => {
2264
+ //
2265
+ // Implementation note: we use `outerHTML` to swap the element rather than
2266
+ // `setAttribute` per-attribute. Alpine's special attribute names (`@click`,
2267
+ // possibly others starting with `@`) are not valid DOM Names per the XML
2268
+ // production, so `setAttribute('@click', …)` throws InvalidCharacterError.
2269
+ // The HTML parser, on the other hand, is lenient and accepts these names.
2270
+ // Building an HTML string and assigning it via outerHTML round-trips through
2271
+ // the parser and produces an element with all source attributes intact.
2272
+ // Stop Alpine from observing further DOM mutations and flush any pending
2273
+ // effects. Then restore each hydrate target by replacing it with a fresh
2274
+ // element parsed from a source-attribute HTML string. Replacing the element
2275
+ // (rather than mutating attributes in place) detaches it from Alpine's
2276
+ // reactive bindings entirely — the new node has no `_x_*` state, no
2277
+ // effects, and no observers. Alpine's MutationObserver is stopped first
2278
+ // so it can't pick up the new node and re-process it.
2279
+ //
2280
+ // We process snapshots deepest-first so that when an ancestor is rebuilt,
2281
+ // its children have already been replaced with their pristine versions and
2282
+ // are captured (via innerHTML) into the new ancestor.
2283
+ const restoreReport = await page.evaluate(async () => {
2284
+ try { window.Alpine && window.Alpine.flushAndStopDeferringMutations && window.Alpine.flushAndStopDeferringMutations(); } catch (_) {}
2285
+ try { window.Alpine && window.Alpine.stopObservingMutations && window.Alpine.stopObservingMutations(); } catch (_) {}
2286
+ await Promise.resolve();
2287
+ await Promise.resolve();
2288
+
2206
2289
  const snapshots = window.__manifestHydrateSnapshots || [];
2290
+ const report = { total: snapshots.length, restored: 0, notFound: 0, errors: [] };
2291
+
2292
+ // Resolve every snapshot to its element, then sort by depth (deepest first).
2293
+ const items = [];
2207
2294
  snapshots.forEach(({ id, attrs }) => {
2208
2295
  const el = document.querySelector(`[data-manifest-hyd-id="${id}"]`);
2209
- if (!el) return;
2210
- // Wipe everything Alpine may have added or mutated.
2211
- Array.from(el.attributes).map((a) => a.name).forEach((name) => el.removeAttribute(name));
2212
- // Put the original source attributes back.
2213
- Object.entries(attrs).forEach(([name, value]) => {
2214
- el.setAttribute(name, value);
2215
- });
2216
- // Marker so downstream stripping (xfor processing, dynamic-binding strip,
2217
- // component pre-render marking, etc.) skips this element.
2218
- el.setAttribute('data-prerender-hydrate', '1');
2296
+ if (!el) { report.notFound++; return; }
2297
+ let depth = 0;
2298
+ for (let p = el.parentNode; p; p = p.parentNode) depth++;
2299
+ items.push({ id, el, attrs, depth });
2219
2300
  });
2301
+ items.sort((a, b) => b.depth - a.depth);
2302
+
2303
+ const voidEls = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
2304
+ const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
2305
+
2306
+ items.forEach(({ id, attrs }) => {
2307
+ // Re-resolve the element by id every iteration: ancestors that were
2308
+ // already rebuilt will have re-parsed their children, so previous
2309
+ // references are stale.
2310
+ const el = document.querySelector(`[data-manifest-hyd-id="${id}"]`);
2311
+ if (!el || !el.parentNode) { report.errors.push({ id, msg: 'lost reference' }); return; }
2312
+ const tag = el.tagName.toLowerCase();
2313
+ const attrString = Object.entries(attrs)
2314
+ .map(([name, value]) => `${name}="${escAttr(value)}"`)
2315
+ .join(' ');
2316
+ const innerHTML = voidEls.has(tag) ? '' : el.innerHTML;
2317
+ const newHTML = voidEls.has(tag)
2318
+ ? `<${tag} ${attrString} data-prerender-hydrate="1">`
2319
+ : `<${tag} ${attrString} data-prerender-hydrate="1">${innerHTML}</${tag}>`;
2320
+ // Parse via a temporary container so we can use replaceChild (more
2321
+ // reliable than outerHTML in nested-replace scenarios).
2322
+ const tmp = document.createElement(el.parentNode.tagName === 'TR' ? 'tr' : 'div');
2323
+ tmp.innerHTML = newHTML;
2324
+ const parsed = tmp.firstElementChild;
2325
+ if (!parsed) { report.errors.push({ id, msg: 'parse failed' }); return; }
2326
+ try {
2327
+ el.parentNode.replaceChild(parsed, el);
2328
+ report.restored++;
2329
+ } catch (e) {
2330
+ report.errors.push({ id, tag, msg: String(e && e.message || e) });
2331
+ }
2332
+ });
2333
+
2334
+ return report;
2220
2335
  });
2336
+ if (config.debugPrerender) {
2337
+ pushDebug({ path: displayPath, stage: 'hydrate-restore', metrics: restoreReport });
2338
+ }
2221
2339
 
2222
2340
  // x-for lists: keep static lists in the HTML for SEO; collapse only dynamic lists so Alpine re-renders.
2223
2341
  // Explicit: data-prerender="dynamic"|"skip". Inferred: x-for uses $search/$query,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {