@atlassian/chippy 0.0.1-beta.1 → 0.0.1-beta.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 (54) hide show
  1. package/README.md +142 -54
  2. package/bin/chippy.mjs +53 -95
  3. package/bin/pack.mjs +20 -0
  4. package/bin/skill-dir.mjs +15 -0
  5. package/lib/avalanche.js +54 -23
  6. package/lib/avalanche.js.map +2 -2
  7. package/lib/bootstrap.js +20 -1
  8. package/lib/bootstrap.js.map +3 -3
  9. package/lib/extension/avalanche.es.js +173 -34
  10. package/lib/extension/avalanche.es.js.map +1 -1
  11. package/lib/extension/chippy-page-bridge.js +164 -44
  12. package/lib/mcp-skill/README.md +2 -4
  13. package/lib/mcp-skill/SKILL.md +21 -16
  14. package/lib/mcp-skill/extension/armed-reload-startup.js +83 -0
  15. package/lib/mcp-skill/extension/avalanche.es.js +173 -34
  16. package/lib/mcp-skill/extension/avalanche.es.js.map +1 -1
  17. package/lib/mcp-skill/extension/avalanche.iife.js +173 -34
  18. package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -1
  19. package/lib/mcp-skill/extension/background.js +2 -0
  20. package/lib/mcp-skill/extension/chippy-page-bridge.js +164 -44
  21. package/lib/mcp-skill/extension/content-script.js +23 -0
  22. package/lib/mcp-skill/extension/devtools-hook-shim.js +18 -1
  23. package/lib/mcp-skill/extension/early-bootstrap.js +3 -1
  24. package/lib/mcp-skill/extension/manifest.json +1 -1
  25. package/lib/mcp-skill/extension/panel-settings.js +2 -20
  26. package/lib/mcp-skill/extension/startup-handoff.js +30 -0
  27. package/lib/mcp-skill/references/README.md +2 -2
  28. package/lib/mcp-skill/references/avalanche-detector.md +3 -3
  29. package/lib/mcp-skill/references/browser-runtime-api.md +4 -4
  30. package/lib/mcp-skill/references/command-output-formats.md +12 -2
  31. package/lib/mcp-skill/references/command-surface.md +6 -6
  32. package/lib/mcp-skill/references/mode-a-injection.md +43 -9
  33. package/lib/mcp-skill/references/playwright-cli-setup.md +5 -5
  34. package/lib/mcp-skill/references/playwright-probes.md +1 -1
  35. package/lib/mcp-skill/references/recipes.txt +1 -1
  36. package/lib/mcp-skill/references/stability-render.md +4 -4
  37. package/lib/mcp-skill/references/test-mode-playbook.md +2 -1
  38. package/lib/mcp-skill/references/troubleshooting.md +6 -1
  39. package/lib/mcp-skill/scripts/chippy-helper-server.js +116 -58
  40. package/lib/mcp-skill/scripts/chippy.sh +41 -7
  41. package/lib/mcp-skill/scripts/helper-access.js +20 -6
  42. package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
  43. package/lib/mcp-skill/scripts/page-socket-trust.js +81 -0
  44. package/lib/plugin.js.map +2 -2
  45. package/lib/storybook/connection-fallback.jsx +1 -1
  46. package/lib/storybook/manager-owner.js +42 -0
  47. package/lib/storybook/manager.jsx +22 -0
  48. package/lib/storybook/preview.js +53 -18
  49. package/lib/storybook/setup.js +15 -4
  50. package/lib/types/chippy-bridge/fiber-source.d.ts +14 -3
  51. package/lib/types/chippy-bridge/react-renderer.d.ts +30 -5
  52. package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
  53. package/lib/types/debug-render/core.d.ts +6 -0
  54. package/package.json +3 -1
@@ -13,6 +13,8 @@ const DEFAULT_HELPER = 'http://127.0.0.1:7823';
13
13
  // with the plugins it enables until the user saves their own selection.
14
14
  const BUILD_DEFAULT_PLUGINS = Array.isArray(globalThis.__CHIPPY_BUILD?.defaultPlugins) ? globalThis.__CHIPPY_BUILD.defaultPlugins : [];
15
15
  const CONTENT_SCRIPTS = ['panel-settings.js', 'content-script.js'];
16
+ // Registers early startup for the navigation after an armed reload.
17
+ importScripts('armed-reload-startup.js');
16
18
  const EXTENSION_INTEGRATION = Object.freeze({
17
19
  kind: 'extension',
18
20
  who: 'Chippy browser extension',
@@ -8,6 +8,16 @@
8
8
  /** @type {ChippyPageRuntime} */
9
9
  const chippy = global.__CHIPPY = global.__CHIPPY || {};
10
10
  const BRIDGE_VERSION = '0.2.5';
11
+ // Returned by every command that needs __AVALANCHE__.bridge when the Avalanche
12
+ // runtime is absent. The helper reports it as an unavailable runtime rather
13
+ // than an empty result, so callers never read `count: 0` as "nothing found".
14
+ const BRIDGE_MODULE_MISSING = Object.freeze({
15
+ ok: false,
16
+ code: 'bridge-module-missing',
17
+ error: 'bridge module not loaded',
18
+ });
19
+ // Read and removed by startup-handoff.js on the next document.
20
+ const STARTUP_HANDOFF_KEY = '__chippyStartupHandoff';
11
21
  if (chippy.pageBridge) {
12
22
  // Always tear down and reinstall — version-equality skip caused stale code to win.
13
23
  try { chippy.pageBridge.destroy?.(); } catch (e) {}
@@ -37,18 +47,53 @@
37
47
  return config?.integration || global.__CHIPPY_OWNER__?.integration || null;
38
48
  }
39
49
 
50
+ // Same-origin frames share sessionStorage, so each frame keys its id by its
51
+ // position in the frame tree; the Storybook manager and its preview iframe
52
+ // must not share one client id. The top frame keeps the original key.
53
+ function frameKeySuffix() {
54
+ const path = [];
55
+ try {
56
+ /** @type {Window} */
57
+ let current = global;
58
+ while (current !== current.parent && path.length < 16) {
59
+ const siblings = current.parent.frames;
60
+ let index = -1;
61
+ for (let i = 0; i < siblings.length; i += 1) {
62
+ if (siblings[i] === current) { index = i; break; }
63
+ }
64
+ path.unshift(index);
65
+ current = current.parent;
66
+ }
67
+ } catch {
68
+ return path.length ? ':frame' : '';
69
+ }
70
+ return path.length ? ':' + path.join('.') : '';
71
+ }
72
+
40
73
  function getStoredClientId() {
74
+ const key = '__chippyClientId' + frameKeySuffix();
41
75
  try {
42
- const existing = global.sessionStorage?.getItem('__chippyClientId');
76
+ const existing = global.sessionStorage?.getItem(key);
43
77
  if (existing) return existing;
44
78
  const next = global.crypto?.randomUUID?.() || `chippy-${Date.now()}`;
45
- global.sessionStorage?.setItem('__chippyClientId', next);
79
+ global.sessionStorage?.setItem(key, next);
46
80
  return next;
47
81
  } catch {
48
82
  return `chippy-${Date.now()}`;
49
83
  }
50
84
  }
51
85
 
86
+ // The extension is the fallback integration. A live owner of another kind,
87
+ // such as the Storybook addon in its manager window, outranks it.
88
+ function isLiveForeignOwner(owner) {
89
+ if (!owner || !owner.integration || owner.integration.kind === 'extension') return false;
90
+ return !owner.staleAfterMs || Date.now() - (owner.heartbeatAt || 0) <= owner.staleAfterMs;
91
+ }
92
+
93
+ function isExtensionBridge() {
94
+ return config?.integration?.kind === 'extension';
95
+ }
96
+
52
97
  function getStoryId() {
53
98
  const params = new URLSearchParams(global.location.search || '');
54
99
  return params.get('id') || null;
@@ -363,6 +408,7 @@
363
408
  let onLayoutLabUpdate = null;
364
409
  let onWindowMessage = null;
365
410
  let onExtCommand = null;
411
+ let onOwnerClaimed = null;
366
412
  let publishTimer = null;
367
413
  let snapshotSequence = 0;
368
414
  let lastLocationHref = global.location.href;
@@ -542,18 +588,55 @@
542
588
  const b = getBridge();
543
589
  return b
544
590
  ? b.readAvalancheStatus(getAvalancheApi(), getAvalancheModule())
545
- : { ok: false, available: false, error: 'bridge module not loaded' };
591
+ : { ok: false, available: false, ...BRIDGE_MODULE_MISSING };
546
592
  }
547
593
 
548
594
  function evalAvalanche(command) {
549
595
  const b = getBridge();
550
596
  if (!b) {
551
597
  const action = String(command?.action || 'status');
552
- return { ok: false, available: false, action, error: 'bridge module not loaded' };
598
+ return { ok: false, available: false, action, ...BRIDGE_MODULE_MISSING };
553
599
  }
554
600
  return b.evalAvalanche(command, getAvalancheApi(), getAvalancheModule());
555
601
  }
556
602
 
603
+ // An armed reload must install Avalanche before the next document's React
604
+ // commits. The arm config travels in sessionStorage, which the next
605
+ // document's startup script reads synchronously; the extension registers that
606
+ // script for one navigation. Without the extension the wait times out and the
607
+ // page reloads anyway. Returns how early startup was prepared, for the ack.
608
+ function prepareReload(avalanche) {
609
+ if (!avalanche || !avalanche.autoStart) return Promise.resolve('not-armed');
610
+ try {
611
+ const startup = /** @type {any} */ (global).__CHIPPY_STARTUP_CONFIG || {};
612
+ global.sessionStorage.setItem(STARTUP_HANDOFF_KEY, JSON.stringify({
613
+ at: Date.now(),
614
+ helperBaseUrl: config.helperBaseUrl,
615
+ plugins: Array.isArray(startup.plugins) ? startup.plugins : undefined,
616
+ avalanche: avalanche,
617
+ }));
618
+ } catch (e) {
619
+ return Promise.resolve('storage-unavailable');
620
+ }
621
+ return new Promise(function(done) {
622
+ let settled = false;
623
+ function finish(state) {
624
+ if (settled) return;
625
+ settled = true;
626
+ global.document.removeEventListener('chippy-armed-reload-ready', onReady);
627
+ done(state);
628
+ }
629
+ function onReady(event) {
630
+ let detail = {};
631
+ try { detail = JSON.parse(String(event.detail || '{}')); } catch (e) {}
632
+ finish(detail.ok ? 'registered' : 'handoff-only');
633
+ }
634
+ global.document.addEventListener('chippy-armed-reload-ready', onReady);
635
+ setTimeout(function() { finish('handoff-only'); }, 500);
636
+ global.document.dispatchEvent(new CustomEvent('chippy-armed-reload-prepare'));
637
+ });
638
+ }
639
+
557
640
  function handleCommand(command) {
558
641
  switch (command?.type) {
559
642
  case 'activate':
@@ -581,15 +664,19 @@
581
664
  setContextUiEnabled(command.enabled !== false, 'set-context-ui');
582
665
  break;
583
666
  case 'reload':
584
- try { send({ type: 'reload-ack', clientId, requestId: command.requestId || null }); } catch (e) {}
585
- setTimeout(function() { try { window.location.reload(); } catch (e) {} }, 50);
667
+ prepareReload(command.avalanche).then(function(earlyStartup) {
668
+ try { send({ type: 'reload-ack', clientId, requestId: command.requestId || null, earlyStartup: earlyStartup }); } catch (e) {}
669
+ setTimeout(function() { try { window.location.reload(); } catch (e) {} }, 50);
670
+ });
586
671
  break;
587
672
  case 'storybook-force-remount': {
588
673
  // The Storybook addon owns the host API. The bridge only relays the
589
674
  // request so a Storybook SUT remount does not navigate its preview.
675
+ // The arm, when present, lets the addon install Avalanche before the
676
+ // remount commits.
590
677
  const remountEvent = new CustomEvent('chippy-storybook-force-remount', {
591
678
  cancelable: true,
592
- detail: { requestId: command.requestId || null },
679
+ detail: { requestId: command.requestId || null, avalanche: command.avalanche || null },
593
680
  });
594
681
  global.dispatchEvent(remountEvent);
595
682
  try {
@@ -599,6 +686,7 @@
599
686
  requestId: command.requestId || null,
600
687
  action: 'storybook-force-remount',
601
688
  ok: remountEvent.defaultPrevented,
689
+ error: remountEvent.defaultPrevented ? undefined : 'The Storybook preview did not remount: no rendered story matched the current selection',
602
690
  });
603
691
  } catch (e) {}
604
692
  break;
@@ -655,8 +743,9 @@
655
743
  }
656
744
  case 'find-fibers': {
657
745
  const requestId = command.requestId || null;
658
- const result = evalFindFibers(command.filters || {});
659
- send({ type: 'eval-probe-result', clientId, requestId, result });
746
+ Promise.resolve(evalFindFibers(command.filters || {}))
747
+ .then((result) => send({ type: 'eval-probe-result', clientId, requestId, result }))
748
+ .catch((e) => send({ type: 'eval-probe-result', clientId, requestId, result: { count: 0, items: [], error: e?.message || String(e) } }));
660
749
  break;
661
750
  }
662
751
  case 'capture-fiber-state': {
@@ -873,35 +962,40 @@
873
962
  if (!t) return null;
874
963
  return t.displayName || t.name || (typeof t === 'string' ? t : null);
875
964
  }
965
+ // Return the live HostRoot fiber of every React root. A FiberRoot (from
966
+ // `getFiberRoots`) exposes it as `.current`; a HostRoot fiber reached from a
967
+ // DOM container or by climbing `.return` may be the stale alternate, so it
968
+ // is re-resolved through its FiberRoot (`stateNode.current`).
876
969
  function getRootFibers() {
877
970
  const hook = devtoolsHook();
878
971
  const out = [];
879
- if (!hook?.renderers) return out;
880
972
  const seen = new Set();
881
- for (const [rendererId] of hook.renderers) {
882
- try {
883
- if (hook.getFiberRoots) {
973
+ function add(candidate) {
974
+ if (!candidate || typeof candidate !== 'object') return;
975
+ const fiberRoot = candidate.current ? candidate
976
+ : (candidate.tag === 3 && candidate.stateNode && candidate.stateNode.current ? candidate.stateNode : null);
977
+ const live = fiberRoot ? fiberRoot.current : candidate;
978
+ const key = fiberRoot || live;
979
+ if (!live || seen.has(key)) return;
980
+ seen.add(key);
981
+ out.push(live);
982
+ }
983
+ if (hook?.renderers && typeof hook.getFiberRoots === 'function') {
984
+ for (const [rendererId] of hook.renderers) {
985
+ try {
884
986
  const roots = hook.getFiberRoots(rendererId);
885
- if (roots && typeof roots.forEach === 'function') {
886
- roots.forEach((root) => {
887
- if (root && !seen.has(root)) {
888
- seen.add(root);
889
- out.push(root);
890
- }
891
- });
892
- }
893
- }
894
- } catch (_) {}
987
+ if (roots && typeof roots.forEach === 'function') roots.forEach(add);
988
+ } catch (_) {}
989
+ }
895
990
  }
896
- // Robust fallback: walk all root containers via document
991
+ // Fallback for hooks without getFiberRoots: walk root containers in the document.
897
992
  const containers = document.querySelectorAll('[id], body div, body main');
898
993
  containers.forEach((el) => {
899
994
  const key = Object.keys(el).find((k) => k.startsWith('__reactContainer$') || k.startsWith('__reactFiber$'));
900
995
  if (!key) return;
901
996
  let fiber = el[key];
902
- // climb to root
903
997
  while (fiber && fiber.return) fiber = fiber.return;
904
- if (fiber && !seen.has(fiber)) { seen.add(fiber); out.push(fiber); }
998
+ add(fiber);
905
999
  });
906
1000
  return out;
907
1001
  }
@@ -1119,7 +1213,7 @@
1119
1213
  // Delegated to avalanche.iife.js — see src/chippy-bridge/fiber-invoke.ts
1120
1214
  function evalFiberPropContract(fiberId, propName) {
1121
1215
  const b = getBridge();
1122
- if (!b || !b.evalFiberPropContract) return { ok: false, error: 'bridge module not loaded' };
1216
+ if (!b || !b.evalFiberPropContract) return { ok: false, ...BRIDGE_MODULE_MISSING };
1123
1217
  return b.evalFiberPropContract(fiberId, propName, {
1124
1218
  normalizeRememberedFiberId,
1125
1219
  getRememberedFiber,
@@ -1130,7 +1224,7 @@
1130
1224
  // Returns a Promise. The handler routes async responses through `send` once resolved.
1131
1225
  function evalFiberInvoke(fiberId, propName, args) {
1132
1226
  const b = getBridge();
1133
- if (!b || !b.evalFiberInvoke) return Promise.resolve({ ok: false, error: 'bridge module not loaded' });
1227
+ if (!b || !b.evalFiberInvoke) return Promise.resolve({ ok: false, ...BRIDGE_MODULE_MISSING });
1134
1228
  return Promise.resolve(b.evalFiberInvoke(fiberId, propName, args, {
1135
1229
  normalizeRememberedFiberId,
1136
1230
  getRememberedFiber,
@@ -1143,7 +1237,7 @@
1143
1237
  function evalFiberScheduleUpdate(fiberId, ownerIndex) {
1144
1238
  const b = getBridge();
1145
1239
  if (!b || !b.evalFiberScheduleUpdate) {
1146
- return { ok: false, reason: 'no-scheduleUpdate', error: 'bridge module not loaded' };
1240
+ return { ok: false, reason: 'no-scheduleUpdate', ...BRIDGE_MODULE_MISSING };
1147
1241
  }
1148
1242
  return b.evalFiberScheduleUpdate(fiberId, ownerIndex, {
1149
1243
  normalizeRememberedFiberId,
@@ -1154,7 +1248,7 @@
1154
1248
  // Delegated to avalanche.iife.js — see src/chippy-bridge/eval-helpers.ts
1155
1249
  function evalElementDom(element, parentLevel) {
1156
1250
  const b = getBridge();
1157
- if (!b) return { error: 'bridge module not loaded' };
1251
+ if (!b) return { ...BRIDGE_MODULE_MISSING };
1158
1252
  return b.evalElementDom(element, parentLevel, window.getComputedStyle.bind(window));
1159
1253
  }
1160
1254
 
@@ -1167,14 +1261,14 @@
1167
1261
  // Delegated to avalanche.iife.js — see src/chippy-bridge/eval-helpers.ts
1168
1262
  function evalFiberProps(baseFiber, ownerIndex) {
1169
1263
  const b = getBridge();
1170
- if (!b) return { error: 'bridge module not loaded' };
1264
+ if (!b) return { ...BRIDGE_MODULE_MISSING };
1171
1265
  return b.evalFiberProps(baseFiber, ownerIndex, chippy.layoutLab?.getHookSummary, resolveOwnerFiber);
1172
1266
  }
1173
1267
 
1174
1268
  // Delegated to avalanche.iife.js — see src/chippy-bridge/eval-helpers.ts
1175
1269
  function evalFiberPropValue(baseFiber, propName, ownerIndex) {
1176
1270
  const b = getBridge();
1177
- if (!b) return { error: 'bridge module not loaded' };
1271
+ if (!b) return { ...BRIDGE_MODULE_MISSING };
1178
1272
  return b.evalFiberPropValue(baseFiber, propName, ownerIndex, chippy.layoutLab?.getHookSummary, resolveOwnerFiber, safeSerialize);
1179
1273
  }
1180
1274
 
@@ -1188,7 +1282,7 @@
1188
1282
 
1189
1283
  function summarizeHookList(raw) {
1190
1284
  const b = getBridge();
1191
- return b ? b.summarizeHookList(raw) : (raw || { error: 'bridge module not loaded' });
1285
+ return b ? b.summarizeHookList(raw) : (raw || { ...BRIDGE_MODULE_MISSING });
1192
1286
  }
1193
1287
 
1194
1288
  function formatCounts(counts) {
@@ -1372,15 +1466,20 @@
1372
1466
  }
1373
1467
 
1374
1468
  // Delegated to avalanche.iife.js — see src/chippy-bridge/eval-helpers.ts
1375
- function evalFindFibers(filters) {
1469
+ async function evalFindFibers(filters) {
1376
1470
  const b = getBridge();
1377
- if (!b) return { count: 0, items: [], error: 'bridge module not loaded' };
1378
- return b.evalFindFibers(filters, buildScriptHelpers([]), rememberFiber);
1471
+ if (!b) return { count: 0, items: [], ...BRIDGE_MODULE_MISSING };
1472
+ const result = b.evalFindFibers(filters, buildScriptHelpers([]), rememberFiber);
1473
+ // Map each item's source through the served module, as `explain` does.
1474
+ if (b.resolveFindFiberSources && b.resolveFiberSource) {
1475
+ await b.resolveFindFiberSources(result.items, getRememberedFiber, b.resolveFiberSource);
1476
+ }
1477
+ return result;
1379
1478
  }
1380
1479
 
1381
1480
  function evalCaptureFiberState(options) {
1382
1481
  const b = getBridge();
1383
- if (!b?.captureFiberState) return { ok: false, error: 'bridge module not loaded' };
1482
+ if (!b?.captureFiberState) return { ok: false, ...BRIDGE_MODULE_MISSING };
1384
1483
  const helpers = buildScriptHelpers([]);
1385
1484
  return b.captureFiberState({
1386
1485
  ...options,
@@ -1393,7 +1492,7 @@
1393
1492
 
1394
1493
  function evalCompareFiberStates(before, after, options) {
1395
1494
  const b = getBridge();
1396
- if (!b?.compareFiberStates) return { ok: false, error: 'bridge module not loaded' };
1495
+ if (!b?.compareFiberStates) return { ok: false, ...BRIDGE_MODULE_MISSING };
1397
1496
  return b.compareFiberStates(before, after, options);
1398
1497
  }
1399
1498
 
@@ -1401,7 +1500,7 @@
1401
1500
  // extension shim stays version-coupled but implementation-free.
1402
1501
  function evalBoundaryChain(fiber, max) {
1403
1502
  const b = getBridge();
1404
- if (!b) return { ok: false, error: 'bridge module not loaded' };
1503
+ if (!b) return { ok: false, ...BRIDGE_MODULE_MISSING };
1405
1504
  return b.inspectDeclarativeBoundaryChain(fiber, max, {
1406
1505
  rememberFiber,
1407
1506
  getFiberDomNode: getAvalancheApi()?.layoutLab?.getFiberDomNode || getFiberDomNode,
@@ -1422,7 +1521,7 @@
1422
1521
 
1423
1522
  function evalPageBoundaries(max) {
1424
1523
  const b = getBridge();
1425
- if (!b) return { ok: false, error: 'bridge module not loaded' };
1524
+ if (!b) return { ok: false, ...BRIDGE_MODULE_MISSING };
1426
1525
  const helpers = buildScriptHelpers([]);
1427
1526
  return b.inspectPageDeclarativeBoundaries(max, {
1428
1527
  rememberFiber,
@@ -1470,7 +1569,7 @@
1470
1569
  const fiber = getRememberedFiber(fiberId);
1471
1570
  if (!fiber) return { error: 'Unknown fiberId: ' + fiberId };
1472
1571
  const b = getBridge();
1473
- if (!b) return { error: 'bridge module not loaded' };
1572
+ if (!b) return { ...BRIDGE_MODULE_MISSING };
1474
1573
  return b.inspectFiberStructure(fiber, fiberId, hookIndex, windowSize, replayComponentHooks, evalFiberHooks);
1475
1574
  }
1476
1575
 
@@ -1515,7 +1614,7 @@
1515
1614
 
1516
1615
  async function evalDescribeComponent(fiberIdRaw, nameRaw) {
1517
1616
  const b = getBridge();
1518
- if (!b) return { ok: false, error: 'bridge module not loaded' };
1617
+ if (!b) return { ok: false, ...BRIDGE_MODULE_MISSING };
1519
1618
  const target = resolveFiberTarget(null, fiberIdRaw, nameRaw);
1520
1619
  if (target.error) return { ok: false, error: target.error };
1521
1620
  if (b.resolveFiberSource) await b.resolveFiberSource(target.fiber);
@@ -1576,7 +1675,7 @@
1576
1675
  const fiber = getRememberedFiber(fiberId);
1577
1676
  if (!fiber) return { error: 'Unknown fiberId: ' + fiberId };
1578
1677
  const b = getBridge();
1579
- if (!b) return { error: 'bridge module not loaded' };
1678
+ if (!b) return { ...BRIDGE_MODULE_MISSING };
1580
1679
  return b.inspectFiberOwners(fiber, fiberId, buildScriptHelpers([]));
1581
1680
  }
1582
1681
 
@@ -1708,6 +1807,22 @@
1708
1807
  };
1709
1808
  global.document.addEventListener('chippy-ext-command', onExtCommand);
1710
1809
 
1810
+ // An extension bridge that is already running stands down when another
1811
+ // integration claims the page; the background's owner check covers bridges
1812
+ // that have not loaded yet.
1813
+ function standDown(owner) {
1814
+ if (destroyed) return;
1815
+ destroy();
1816
+ try { chippy.layoutLab?.clearAll?.(); } catch (e) {}
1817
+ try { chippy.layoutLab?.removeInPageUi?.(); } catch (e) {}
1818
+ chippy.standDown = { at: Date.now(), owner: owner?.integration || null };
1819
+ setConnState('yielded');
1820
+ }
1821
+ onOwnerClaimed = () => {
1822
+ if (isExtensionBridge() && isLiveForeignOwner(global.__CHIPPY_OWNER__)) standDown(global.__CHIPPY_OWNER__);
1823
+ };
1824
+ global.addEventListener('chippy-owner-claimed', onOwnerClaimed);
1825
+
1711
1826
  locationInterval = global.setInterval(() => {
1712
1827
  if (global.location.href !== lastLocationHref) {
1713
1828
  lastLocationHref = global.location.href;
@@ -1763,6 +1878,7 @@
1763
1878
  try { if (onLayoutLabUpdate) global.removeEventListener('layout-lab:update', onLayoutLabUpdate); } catch (e) {}
1764
1879
  try { if (onWindowMessage) global.removeEventListener('message', onWindowMessage); } catch (e) {}
1765
1880
  try { if (onExtCommand) global.document.removeEventListener('chippy-ext-command', onExtCommand); } catch (e) {}
1881
+ try { if (onOwnerClaimed) global.removeEventListener('chippy-owner-claimed', onOwnerClaimed); } catch (e) {}
1766
1882
  }
1767
1883
 
1768
1884
  // ─── Public API ──────────────────────────────────────────────────────────
@@ -1829,5 +1945,9 @@
1829
1945
  chippy.layoutLab?.exposeDefaultMarkers?.();
1830
1946
  }
1831
1947
 
1832
- connect();
1948
+ if (isExtensionBridge() && isLiveForeignOwner(global.__CHIPPY_OWNER__)) {
1949
+ standDown(global.__CHIPPY_OWNER__);
1950
+ } else {
1951
+ connect();
1952
+ }
1833
1953
  })(window);
@@ -162,6 +162,29 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
162
162
  }
163
163
  });
164
164
 
165
+ // The page bridge asks for early startup on the next navigation before an armed
166
+ // reload. Only the background can register the MAIN-world startup scripts. The
167
+ // background also re-runs this file, so the relay is installed once per document.
168
+ function relayArmedReload() {
169
+ const reply = (result) => {
170
+ try {
171
+ document.dispatchEvent(new CustomEvent('chippy-armed-reload-ready', { detail: JSON.stringify(result || {}) }));
172
+ } catch {}
173
+ };
174
+ try {
175
+ chrome.runtime.sendMessage({ type: 'chippy-register-armed-reload' }, (response) => {
176
+ if (chrome.runtime.lastError) reply({ ok: false, error: chrome.runtime.lastError.message });
177
+ else reply(response);
178
+ });
179
+ } catch (e) {
180
+ reply({ ok: false, error: e.message });
181
+ }
182
+ }
183
+ if (!globalThis.__chippyArmedReloadRelay) {
184
+ globalThis.__chippyArmedReloadRelay = true;
185
+ document.addEventListener('chippy-armed-reload-prepare', relayArmedReload);
186
+ }
187
+
165
188
  applyStoredConfig();
166
189
 
167
190
  })();
@@ -24,6 +24,15 @@
24
24
  if (global.__REACT_DEVTOOLS_GLOBAL_HOOK__) return;
25
25
 
26
26
  var renderers = new Map();
27
+ // Committed FiberRoots per renderer, tracked like React DevTools does so
28
+ // tree walks start from the live root instead of a container's stale
29
+ // HostRoot alternate.
30
+ var fiberRoots = new Map();
31
+ function rootsFor(id) {
32
+ var roots = fiberRoots.get(id);
33
+ if (!roots) { roots = new Set(); fiberRoots.set(id, roots); }
34
+ return roots;
35
+ }
27
36
  var hook = {
28
37
  __chippyShim: true,
29
38
  supportsFiber: true,
@@ -33,8 +42,16 @@
33
42
  renderers.set(id, renderer);
34
43
  return id;
35
44
  },
45
+ getFiberRoots: function getFiberRoots(id) { return rootsFor(id); },
36
46
  onScheduleFiberRoot: function () {},
37
- onCommitFiberRoot: function () {},
47
+ onCommitFiberRoot: function onCommitFiberRoot(id, root) {
48
+ if (!root || !root.current) return;
49
+ var roots = rootsFor(id);
50
+ var state = root.current.memoizedState;
51
+ var unmounting = !state || state.element == null;
52
+ if (unmounting) roots.delete(root);
53
+ else roots.add(root);
54
+ },
38
55
  onCommitFiberUnmount: function () {},
39
56
  onPostCommitFiberRoot: function () {},
40
57
  };
@@ -8,12 +8,14 @@
8
8
  if (globalThis.__CHIPPY_EARLY_BOOTSTRAP_INSTALLED__) {
9
9
  return;
10
10
  }
11
- globalThis.__CHIPPY_EARLY_BOOTSTRAP_INSTALLED__ = true;
12
11
 
12
+ // Without a config this run does nothing, so a later run that has one (the
13
+ // background's startup injection) must still be able to bootstrap.
13
14
  const cfg = globalThis.__CHIPPY_STARTUP_CONFIG || null;
14
15
  if (!cfg || !cfg.enabled) {
15
16
  return;
16
17
  }
18
+ globalThis.__CHIPPY_EARLY_BOOTSTRAP_INSTALLED__ = true;
17
19
 
18
20
  globalThis.__CHIPPY = globalThis.__CHIPPY || {};
19
21
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chippy Probe Bridge",
4
- "version": "0.4.24",
4
+ "version": "0.4.25",
5
5
  "description": "Inspects connected React pages to trace components, boundaries, hooks, props, and render behavior.",
6
6
  "permissions": [
7
7
  "storage",
@@ -30,12 +30,6 @@
30
30
  }
31
31
  }
32
32
 
33
- function parseForwardPort(value) {
34
- if (value === '') return null;
35
- const port = Number(value);
36
- return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
37
- }
38
-
39
33
  function escapeHtml(value) {
40
34
  return String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
41
35
  }
@@ -74,10 +68,6 @@
74
68
  <span>Chippy URL</span>
75
69
  <input data-helper-url type="url" inputmode="url" required placeholder="${DEFAULT_HELPER}" />
76
70
  </label>
77
- <label>
78
- <span>Forward to port · optional</span>
79
- <input data-forward-port type="number" inputmode="numeric" min="1" max="65535" step="1" placeholder="Auto-detect from page URL" />
80
- </label>
81
71
  ${pluginMarkup()}
82
72
  <div class="actions">
83
73
  <button type="submit">Save settings</button>
@@ -91,7 +81,7 @@
91
81
  host.setAttribute('data-chippy-settings-host', 'true');
92
82
  host.setAttribute('data-chippy-preserve', 'true');
93
83
  const pluginRowsHeight = PLUGIN_OPTIONS.length ? 18 + 19 * PLUGIN_OPTIONS.length : 0;
94
- host.style.cssText = `display:block;min-height:${176 + pluginRowsHeight}px;`;
84
+ host.style.cssText = `display:block;min-height:${124 + pluginRowsHeight}px;`;
95
85
  const root = host.attachShadow({ mode: 'closed' });
96
86
  const surface = document.createElement('div');
97
87
  surface.innerHTML = settingsMarkup();
@@ -99,7 +89,6 @@
99
89
 
100
90
  const form = root.querySelector('form');
101
91
  const helperInput = root.querySelector('[data-helper-url]');
102
- const forwardPortInput = root.querySelector('[data-forward-port]');
103
92
  const pluginInputs = Array.from(root.querySelectorAll('[data-plugin]'));
104
93
  const statusNode = root.querySelector('[data-status]');
105
94
 
@@ -110,11 +99,9 @@
110
99
 
111
100
  chrome.storage.local.get({
112
101
  helperBaseUrl: DEFAULT_HELPER,
113
- forwardPort: null,
114
102
  plugins: DEFAULT_PLUGINS,
115
103
  }).then((stored) => {
116
104
  helperInput.value = normalizeBaseUrl(stored.helperBaseUrl);
117
- forwardPortInput.value = stored.forwardPort || '';
118
105
  const plugins = Array.isArray(stored.plugins) ? stored.plugins : [];
119
106
  for (const input of pluginInputs) input.checked = plugins.includes(input.value);
120
107
  }).catch((error) => setStatus(error.message || 'Could not load settings.', true));
@@ -122,19 +109,14 @@
122
109
  form.addEventListener('submit', async (event) => {
123
110
  event.preventDefault();
124
111
  const helperBaseUrl = normalizeBaseUrl(helperInput.value);
125
- const forwardPort = parseForwardPort(forwardPortInput.value);
126
112
  if (!validateHelperUrl(helperBaseUrl)) {
127
113
  setStatus('Use an http(s) URL.', true);
128
114
  return;
129
115
  }
130
- if (forwardPort === undefined) {
131
- setStatus('Port must be 1–65535.', true);
132
- return;
133
- }
134
116
 
135
117
  try {
136
118
  const plugins = pluginInputs.filter((input) => input.checked).map((input) => input.value);
137
- await chrome.storage.local.set({ helperBaseUrl, forwardPort, plugins });
119
+ await chrome.storage.local.set({ helperBaseUrl, plugins });
138
120
  document.dispatchEvent(new CustomEvent('chippy-ext-command', {
139
121
  detail: { type: 'apply-config', helperBaseUrl, activated: true },
140
122
  }));
@@ -0,0 +1,30 @@
1
+ /* eslint-disable */
2
+ // Runs in MAIN at document_start, before avalanche.iife.js, on the one
3
+ // navigation that follows an armed reload. The page bridge stored the arm in
4
+ // sessionStorage just before reloading; reading it here is synchronous, so the
5
+ // startup config exists when Avalanche evaluates and the detector installs
6
+ // before React's first commit. The entry is removed on read and ignored when old.
7
+ (function chippyStartupHandoff(global) {
8
+ var KEY = '__chippyStartupHandoff';
9
+ var MAX_AGE_MS = 60000;
10
+ var handoff = null;
11
+ try {
12
+ var raw = global.sessionStorage.getItem(KEY);
13
+ if (raw == null) return;
14
+ global.sessionStorage.removeItem(KEY);
15
+ handoff = JSON.parse(raw);
16
+ } catch (_) {
17
+ return;
18
+ }
19
+ if (!handoff || !handoff.avalanche || !handoff.avalanche.autoStart) return;
20
+ if (!(Date.now() - Number(handoff.at) <= MAX_AGE_MS)) return;
21
+ if (global.__CHIPPY_STARTUP_CONFIG) return;
22
+ global.__CHIPPY_STARTUP_CONFIG = {
23
+ enabled: true,
24
+ helperBaseUrl: handoff.helperBaseUrl,
25
+ avalanche: handoff.avalanche,
26
+ plugins: Array.isArray(handoff.plugins) ? handoff.plugins : undefined,
27
+ contextUiEnabled: true,
28
+ startupSource: 'armed-reload-handoff',
29
+ };
30
+ })(typeof globalThis !== 'undefined' ? globalThis : window);
@@ -39,9 +39,9 @@ These pages are siblings, not substitutes. Start with Avalanche for a real slow
39
39
  |---|---|---|
40
40
  | Load Chippy into a Playwright-controlled page | [Playwright CLI setup](playwright-cli-setup.md) | The shortest current-document bootstrap and reload recovery recipe |
41
41
  | Build a repeatable browser loop | [Test mode playbook](test-mode-playbook.md) | Sidecar and Playwright-controlled automation contracts |
42
- | Control low-level injection | [Mode A injection](mode-a-injection.md) | Fresh-page script order, boot-time limitations, and failure signatures |
42
+ | Load Chippy before page scripts, or record the initial load without the extension | [Mode A injection](mode-a-injection.md) | Init-script loader variants, pre-page start-up, boot-time limits, and failure signatures |
43
43
 
44
- Use Playwright CLI setup for ad-hoc inspection. Use the test-mode playbook and Mode A details only for scripted or CI workflows.
44
+ Use Playwright CLI setup for ad-hoc inspection. Use the test-mode playbook for scripted or CI workflows. Use Mode A injection when Chippy must stay across reloads or record the initial load in a Playwright browser.
45
45
 
46
46
  ## Look up a contract
47
47
 
@@ -92,7 +92,7 @@ Look for `installed`, `hasMetricsGlobal`, `commitCount`.
92
92
  ```bash
93
93
  chippy.sh reload-with-avalanche my-client http://localhost:9000 0 15000 5000
94
94
  ```
95
- Arms, reloads, waits for reconnect, waits settle window, then prints capture status.
95
+ Arms, reloads, waits for reconnect, waits settle window, then prints capture status. With the extension or proxy mode, the reloaded document installs Avalanche before React's first commit, so boot commits are included. The reload step's ack reports `earlyStartup`: `registered` (extension), `handoff-only` (no extension; proxy mode still captures boot), or `not-armed`.
96
96
 
97
97
  ### `avalanche-hot-sources <clientId> [limit]`
98
98
  ```bash
@@ -175,7 +175,7 @@ chippy.sh avalanche-offline .chippy-captures/raf-1846.json source-search --name
175
175
  ```
176
176
  Use saved captures without a live page. `commit-list`, `commit-peek`, `hot-sources`, and `describe-action` run the same code as the live commands and return the same shapes, so sort keys and include values match the live tables above (`unmounts` is live-only).
177
177
 
178
- `summary` returns commit count, first/last commit id, source count, and totals of the commit stats. `source-search --name <regex>` matches aggregate sources by `sourceId` and, for `detectionMode=full` captures, rendered components by name with per-instance render and self-source counts.
178
+ `summary` returns commit count, first/last commit id, source count, and totals of the commit stats. `source-search --name <regex>` matches aggregate sources by `sourceId` and rendered components by name with per-instance render and self-source counts.
179
179
 
180
180
  ### `avalanche-arm <clientId> <origin> [maxCommits]`
181
181
  ```bash
@@ -263,7 +263,7 @@ Suspense reading:
263
263
 
264
264
  ## `renderedByCommit` — per-component render evidence
265
265
 
266
- Populated when the detector is armed with `detectionMode: 'full'`. Empty under `detectionMode: 'sources'` or `'stats'`. Pulled into `commit-peek` output via `include=rendered`.
266
+ Populated in every detection mode. The mode decides which fields carry evidence: `stats` has no source tracking, so `isSelfSource` stays `false`; `trasherCounts` stay zero unless the mode is `full` or `amplifiers`. Pulled into `commit-peek` output via `include=rendered`.
267
267
 
268
268
  Top-level shape (`?` = optional, may be absent or `null`; everything else is required):
269
269