Skip to content

feat: Chrome Performance panel tracks for Solid (@solidjs/web/performance-tracks) - #3580

Merged
ryansolid merged 15 commits into
nextfrom
feat/performance-tracks
Sep 22, 2026
Merged

ryansolid merged 15 commits into
nextfrom
feat/performance-tracks

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Summary

@solidjs/web/performance-tracks: Solid's attribution records rendered as custom tracks on Chrome's Performance panel, through the panel's extensibility API (console.timeStamp / performance.measure with detail.devtools) — the same mechanism React 19.2's Performance Tracks use from react-dom. Same records, two renderings: the diagnostics artifact for an agent, the track for the human, consistent because they are one stream and every label comes from the shared formatters (formatOrigin, formatRerun, ownerPath).

import { enablePerformanceTracks } from "@solidjs/web/performance-tracks";
const disable = enablePerformanceTracks(); // dev and observe tiers; a no-op in prod

Plan and decisions: documentation/plans/chrome-performance-tracks-plan.md. User docs: documentation/solid-2.0/08-dev-diagnostics.md, "Chrome Performance panel".

Track Reads as
Interactions input delay → handler → settle; silent hold = warning
Propagation one wave per scheduler drain (count 0 → 1 — click on button#next · 5 runs, 1 unchanged), the runs beneath it labelled node ← cause
Effects / Memos re-runs, creation runs and effect callbacks by owner path; colour by self time; warning when the run changed nothing
Async flights kickoff → landing (abandoned = warning); fallbacks displayed → hidden
Holds the wait, by blockers; warning silent, error long — the engine's own verdicts
Navigations request → settle, by route
Server server-function calls; frame streams with a shell sub-span
Timings one marker per diagnostic finding, performanceIssue at warn+

Where this differs from React

  • Propagation, not Components. Nothing re-renders in Solid, so a component flame answers the wrong question. The Propagation track paints the graph a write travelled: a wide flat wave is a coarse signal everyone depends on, a deep one a chain of memos, a warning node with nothing after it the equality cutoff at work.
  • Runs in production observe builds, not only dev/profiling: the tracks are one consumer of the shared attribution engine, beside the responsiveness findings and an APM adapter. enable()/disable() are ref-counted so consumers coexist.
  • Findings on the timeline. Every DiagnosticEvent (silent hold, waterfall, unstable memo…) is a marker with a performanceIssue annotation linking its repair guide; React's tracks carry no verdicts.
  • INP join. InteractionEvent.at is the browser event's timeStamp, so PerformanceEventTiming.startTime === interaction.at joins Solid's interaction span to Chrome's INP entry directly.
  • JSX-site stacks in dev via console.createTask on the component record.

Commits

One per stage; each with its changeset (all patch, prerelease).

  • b512cb30d Stage 0–2 — ref-counted engine enable/disable, checks option, isSilentHold/isLongHold, at/inputDelayMs; the adapter; five listener-gated timeline records (flush, create, effect, flight, fallback) and the flushStart hook. effectRunStart/End move from __DEV__ to __OBSERVE__; a 27% enabled-path regression found and removed (effect frames registered lazily).
  • fc03377b1 Stage 3 — Propagation track; ChangeRecord.nodeId; flow-control internals named in observe (children, boundary, value, reveal order, conditions).
  • 8cbe1e613 3c285b2a2 ed273ecf3 Stage 3.5 — compilers: componentNames → sourceNames: boolean | { components?, bindings? }. Binding effects named by target (span.textContent, div.class:active, div.spread/div.children); a separate transformSourceNames(code, opts) pass (Oxc, plain JS in/out, runs on .ts too — not a transform() option, since primitives live outside components) names primitives after their declared identifier; stores honour name. The @solidjs/vite-plugin side is staged separately and waits on the compiler release.
  • 1e26978ed Stage 4 — findings as Timings markers + performanceIssue; console.createTask stacks; Owner path / Node id / Origin properties; diagnosticGuideUrl export.
  • a23eb4d04 Size audit — all source-name plumbing folded out of prod (spread labels via a module-level spreadName, boundary names gate the call, createStore's name branch under __OBSERVE__). Minified prod bundles are structurally identical to next (same byte count, identifier-normalised diff empty); the ±50 B brotli movement is esbuild's name assignment, documented in the caps.
  • 34fc47036 Fallback record fix (found rebasing over fix(signals): Loading on follows the frame; DEV LOADING_ON_OUTSIDE_HOLD (#3540) #3575) — a <Loading> swap is a staged write that lands with its frame; the engine timed the fallback from the swap and so recorded spinners that were never on screen (the LOADING_ON_OUTSIDE_HOLD shape: committed and cleared by the same sweep before any effect ran). boundaryFallback now carries the transaction the swap is staged in; the engine stamps at at the flushEnd of the drain that rendered it and drops an open hidden before that. Two tests pin both product-page shapes.

Review fixes (the six commits after the stage commits)

  • One adapter instance per page: enablePerformanceTracks() while enabled joins the running instance (its options are not re-read) and returns its own release; the instance is torn down at the last release. An HMR re-evaluation no longer paints twice or strands a hold.
  • ./performance-tracks carries node/worker/deno conditions resolving to the inert artifact under every posture — verified under node --conditions=development: no closure, no measures, no hold. Pinned by a test on the manifest.
  • The Emitter guards every performance/console call: a throw drops the entry (dev warns once) and never reaches the engine's record loop.
  • rich: false falls back to performance.measure where console.timeStamp is missing (mirror of the existing fallback); rich mode clears a User Timing name only while the timeline holds no more entries of it than the adapter made, so an app measure sharing a label is left alone.
  • stagedFallbacks is a WeakMap keyed by the transaction (DRAIN is an object key): a transaction dropped without settling or merging releases its staged opens.
  • Stale "Scheduler track" wording in the timeline-records changeset → Propagation; docs now say transformSourceNames is the compiler pass and sourceNames.primitives the Vite plugin's option.
  • Item 5 — attribution.enable(opts) returns the release of its hold (idempotent, like subscribe); disable() is the full teardown whatever holds are outstanding, so enable(); enable(); disable() cannot strand a hold (the provenance test idiom). Options combine across holds by the most demanding request per key (booleans OR, historyLimit max, a threshold config over false, between configs the bound that fires sooner) — a hold adds to what the engine does and never takes away what another asked for, so the result is independent of enable order: a track's log: false beside a console session leaves the log alone; a capture with tight thresholds beside a records-only adapter runs the checks for its own duration. Prior art: wake-lock sentinels / Rx refCount for the hold, Node trace_events (union of enabled categories) and CDP per-session enablement for the merge. The adapter and @solidjs/diagnostics captures release through the token.
  • Item 6 — dev builds create the component's console.createTask task only while an attribution engine is installed (OBSERVE.attribution.installed !== null). A dev session with nothing enabled pays nothing; the tracks enabled at bootstrap see every component's site. Observe and prod artifacts never contained the call (verified: createTask appears in solid.dev.js only).

Public API changes

Breaking

  • @solidjs/babel-plugin / @solidjs/compiler: option componentNames: boolean removed (no alias) → sourceNames: boolean | { components?: boolean; bindings?: boolean }. true turns every kind on. bindings is DOM output only.
  • @solidjs/signals attribution.enable(opts): () => void — each call is a hold and returns its release (was void); options combine across live holds by the most demanding request per key (a released hold withdraws its requests; an explicit undefined is unsaid and takes the default); the engine is uninstalled at the last release. disable() is the full teardown whatever holds are outstanding (unchanged in effect from next, where one disable() uninstalled). Prod twin: enable returns a no-op.
  • @solidjs/signals InteractionEvent.at is now the browser event's timeStamp (the INP join) rather than the engine's performance.now() at dispatch; the dispatch-side delay is the new inputDelayMs.

Added

  • @solidjs/web: new export path ./performance-tracks — enablePerformanceTracks(options?: PerformanceTracksOptions): () => void, PerformanceTracksOptions { attribution?, minMs?, rich?, group?, scrub? }. Browser dev/observe artifacts; inert under node/worker/deno and in prod.
  • @solidjs/signals: five record types on AttributionRecords / attribution.subscribe(type, …) — create (CreateEvent), effect (EffectRunEvent), flush (FlushEvent), flight (FlightEvent), fallback (FallbackEvent). Built only while a listener exists.
  • @solidjs/signals: isSilentHold(hold), isLongHold(hold) (the engine's hold verdicts, exported; () => false in the prod twin); AttributionOptions.checks?: boolean (records only when false); InteractionEvent.inputDelayMs?; ChangeRecord.nodeId; StoreOptions.name honoured for property-node labels (todos.title).
  • @solidjs/compiler: transformSourceNames(code, { filename?, sourceMap? }) / transformSourceNamesAsync and TransformSourceNamesOptions — the primitives-naming pass.
  • solid-js (client and server): ownerPath(owner) re-exported from @solidjs/signals; diagnosticGuideUrl(code) — the repair guide's section URL for a diagnostic code.
  • @solidjs/web compile targets: effect(fn, effectFn, options?: { scope?, name? }) gains name; insert(parent, accessor, marker?, init?, options?) gains options.name; spread(node, props, skipChildren?, skip?, name?) gains a fifth name argument. Emitted by the compilers under sourceNames.bindings; ignored by production runtimes (prod output is structurally identical to next).
  • Dev-only, on the component record: _component.task (a console.createTask task, present only for components rendered while an attribution engine is installed) — internal shape, listed for completeness.

Internal hooks changed (@solidjs/signals AttributionHooks, not a public export): flushStart added; boundaryFallback(boundary, tree, shown, transition?) gains the transaction argument; effectRunStart/End now fire under __OBSERVE__ (were __DEV__).

Cost

Verification

CI green on the head (test, coverage, types, size, benchmarks). Locally after the second rebase (over #3559, #3562, #3584): compiler 5844 / babel-plugin 265 / signals 3537 / solid 655 / web 894 / diagnostics 34 green; types clean; size-limit green. Verified visually in Chrome on a scratch Vite page: tracks, waves, markers with the Insights annotation, and the JSX-site stack on a span.

Follow-ups (not in this PR)

  • @solidjs/vite-plugin sourceNames integration (worktree staged; needs the compiler release).
  • Stage 5 (deferred in the plan): tracks for the server render, and reading the attribution artifact back into the panel offline.
  • D2 in the responsiveness plan (what checks defaults to under a records-only consumer) is still open there.

@changeset-bot

changeset-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e7ebb5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
solid-js Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
test-integration Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ryansolid
ryansolid force-pushed the feat/performance-tracks branch from 34fc470 to a69f52d Compare September 22, 2026 19:00
ryansolid added a commit that referenced this pull request Sep 22, 2026
…per hold; dev component tasks only under an installed engine

Review items 5 and 6 on #3580.

- attribution.enable(opts) returns an idempotent release for the hold it
  takes. Options in effect = defaults with each live hold's opts layered in
  hold order, recomputed on release — a track's log:false beside a console
  session gives the log back when it leaves. disable() is the full teardown
  whatever holds are outstanding, so enable();enable();disable() cannot
  strand a hold. Prod twin: enable returns noop.
- performance-tracks releases through the token and layers log:false only
  when it is the consumer installing the engine (OBSERVE.attribution.installed
  === null); joining leaves a co-holder's options alone.
- solid-js dev: the component wrapper creates the console.createTask task
  only while an attribution engine is installed. The per-call stack capture
  roughly doubled dev mount (10k components 1.9 -> 4.1 ms) for sessions with
  nothing enabled; observe and prod builds never contained the call.
- Tests: layered options + release, disable teardown, adapter join vs
  install log default, task gating; docs and changesets updated.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Sep 22, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 35791922360

Coverage increased (+0.08%) to 72.852%

Details

  • Coverage increased (+0.08%) from the base build.
  • Patch coverage: 5 of 5 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1073
Covered Lines: 827
Line Coverage: 77.07%
Relevant Branches: 824
Covered Branches: 555
Branch Coverage: 67.35%
Branches in Coverage %: Yes
Coverage Strength: 17.56 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing feat/performance-tracks (5e7ebb5) with next (43fae6e)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

ryansolid added a commit that referenced this pull request Sep 22, 2026
…per hold; dev component tasks only under an installed engine

Review items 5 and 6 on #3580.

- attribution.enable(opts) returns an idempotent release for the hold it
  takes. Options in effect = defaults with each live hold's opts layered in
  hold order, recomputed on release — a track's log:false beside a console
  session gives the log back when it leaves. disable() is the full teardown
  whatever holds are outstanding, so enable();enable();disable() cannot
  strand a hold. Prod twin: enable returns noop.
- performance-tracks releases through the token and layers log:false only
  when it is the consumer installing the engine (OBSERVE.attribution.installed
  === null); joining leaves a co-holder's options alone.
- solid-js dev: the component wrapper creates the console.createTask task
  only while an attribution engine is installed. The per-call stack capture
  roughly doubled dev mount (10k components 1.9 -> 4.1 ms) for sessions with
  nothing enabled; observe and prod builds never contained the call.
- Tests: layered options + release, disable teardown, adapter join vs
  install log default, task gating; docs and changesets updated.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the feat/performance-tracks branch from 76ae0e4 to f491538 Compare September 22, 2026 21:03
ryansolid added a commit that referenced this pull request Sep 22, 2026
…per hold; dev component tasks only under an installed engine

Review items 5 and 6 on #3580.

- attribution.enable(opts) returns an idempotent release for the hold it
  takes. Options in effect = defaults with each live hold's opts layered in
  hold order, recomputed on release — a track's log:false beside a console
  session gives the log back when it leaves. disable() is the full teardown
  whatever holds are outstanding, so enable();enable();disable() cannot
  strand a hold. Prod twin: enable returns noop.
- performance-tracks releases through the token and layers log:false only
  when it is the consumer installing the engine (OBSERVE.attribution.installed
  === null); joining leaves a co-holder's options alone.
- solid-js dev: the component wrapper creates the console.createTask task
  only while an attribution engine is installed. The per-call stack capture
  roughly doubled dev mount (10k components 1.9 -> 4.1 ms) for sessions with
  nothing enabled; observe and prod builds never contained the call.
- Tests: layered options + release, disable teardown, adapter join vs
  install log default, task gating; docs and changesets updated.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the feat/performance-tracks branch from f491538 to a2634a3 Compare September 22, 2026 22:04
ryansolid and others added 15 commits September 22, 2026 15:19
…ion, adapter, timeline records

Stage 0 — shared engine foundation (@solidjs/signals, @solidjs/web):
- attribution.enable()/disable() are ref-counted: the engine is shared by
  every consumer on the page (a profiler track, an APM adapter, a
  diagnostics capture); the first hold installs, each enable() opens a fresh
  window over the ring buffers and folds without disturbing live tracking or
  others' subscriptions, the last disable() uninstalls.
- `checks` option: one switch for the five thresholded cost checks, for
  records-only consumers.
- isSilentHold / isLongHold exported, so adapters paint the engine's own
  verdicts rather than re-deriving thresholds.
- dispatchAsInteraction passes at: e.timeStamp; InteractionEvent gains
  inputDelayMs (event creation → handler entry, the INP input-delay term) and
  handlerMs measures from the handler's actual start.
- ownerPath re-exported from solid-js (client from @solidjs/signals, server
  via the ownerLabels twin).

Stage 1 — @solidjs/web/performance-tracks:
- enablePerformanceTracks() paints rerun / interaction / hold / navigation
  records and the web runtime's call / frame records as tracks in the
  `Solid` group, retroactively from the records' own performance.now()
  stamps: performance.measure with detail.devtools (rich: tooltips,
  properties; dev default) or the six-argument console.timeStamp (observe
  default). Track seeding for stable display order, minMs floor, scrub mode
  for shared traces, ref-counted disable. Prod artifact folds to a no-op.
- rollup dev/observe/prod entries, ./performance-tracks export conditions,
  types script.

Stage 2 — timeline records (@solidjs/signals, solid-js, @solidjs/web):
- Five listener-gated records on attribution.subscribe: create (a
  computation's creation run), effect (an effect callback, timed and joined
  to its compute run), flush (one scheduler drain), flight (an async flight
  to landing or abandonment), fallback (a loading boundary's fallback from
  show to hide). None is built, logged or folded unless a listener for its
  type exists; OBSERVE.subjectOf answers for the node-bearing ones.
- Core: flushStart hook beside flushEnd (one drain, never nested);
  effectRunStart now fires in observe like its effectRunEnd twin, so writes
  inside effect callbacks carry their effect origin in observe too. Observe
  core +67 B minified; prod byte-identical.
- The effect-frame → node map is filled by the first write inside a
  callback rather than by every callback (every reader resolves it through
  a write's origin; most callbacks never write). Enabled-engine cost per
  effect callback is at or below `next` on every configuration measured.
- Adapter: creation runs and callbacks on Effects/Memos, drains on a new
  Scheduler track, flights and fallbacks on a new Async track.

Tests: ref-counting, checks, inputDelayMs, the adapter spec (18), the
timeline records (12), dist-artifact coverage of both new hooks per tier.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…s, runs labelled by cause, flow internals folded

Replaces the Scheduler track. Nothing re-renders in Solid, so React's
component flame has no counterpart; the picture is the graph a write
travelled. Each drain paints as a wave named by its root writes and the
interaction (`count 0 → 1 — click on button#next · 5 runs, 1 unchanged`);
compute runs, creations and effect callbacks paint inside it at their own
time labelled `node ← cause`, and the panel stacks them beneath the wave.

Labels fold framework structure into what the developer wrote: flow-control
internals (Show/Switch/Loading/Errored/Reveal) present as the tag,
`primitive.local` names as the primitive, the runtime name kept in a
`Node` property. The boundary nodes and Switch's condition builder are now
named in observe builds so the fold has names to key on.

Engine: ChangeRecord.nodeId on writes and derived changes, same id space as
RerunEvent.nodeId, so a derived cause joins the run that produced it.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The option becomes the home for every kind of source name the compilers
carry into output for the dev/observe runtimes to label the graph with;
`components` is the existing behavior, further kinds follow. Object form
picks kinds; `true` turns on every kind.

Both compilers, the node adapter's validation (rejects the old name and
malformed shapes), typings, READMEs, tests, and the web server test config.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ir target

Attribute effects get `<tag>.<attribute>` as written (`span.textContent`,
`div.class:active`; a merged effect lists all its bindings), holes
`<tag>.children` through insert's options, spreads pass the tag and the
runtime labels `<tag>.spread` / `<tag>.children`. Static holes (component
calls, literals) create no effect and get no name. `prop:` the locked DOM
property pre-pass added is undone in the label (`input.value`).

@solidjs/web: `effect`/`insert` take `{ name }`, `spread` a trailing tag;
names land on the render effect nodes for the dev/observe tiers.

Both compilers, parity fixtures (dom-source-names suite replaces
dom-component-names, run with `sourceNames: true`), option validation,
runtime spec, READMEs.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…red identifier

The sourceNames.primitives half of Stage 3.5, as its own native pass since
it is plain JavaScript in and out and applies to .ts/.js modules: calls
resolving to solid-js / @solidjs/signals imports (createSignal, createMemo,
createOptimistic, createStore, createOptimisticStore, createProjection) gain
{ name } from the array pattern, binding, property key, or class field they
initialise, prefixed with the enclosing non-component function so a composed
primitive's nodes fold under it. Never overrides an explicit name; skips
spread/opaque options and the ambiguous two-argument createStore(x, y).

Stores now honour options.name in the observe tiers: property nodes read
todos.title instead of store.title, derived/optimistic stores included.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…r node properties

Stage 4 of the Chrome performance tracks plan.

- Every DiagnosticEvent delivered while the tracks are enabled is a marker
  on the panel's Timings track (`SILENT_HOLD — <App> › <Search>`), coloured
  by severity; at warn or worse it carries detail.devtools.performanceIssue
  for the Insights sidebar, with the repair guide's section as learnMoreUrl.
  info stays a plain marker. Under the scrub only code, kind and owner travel.
  Plain mode uses the one-argument console.timeStamp.
- Dev: observedComponent stores console.createTask(label) on _component.task;
  the adapter emits every span and marker inside the nearest component's task
  so the entry's stack in the panel is the JSX site (React's _debugTask).
- Rich mode adds Owner path (unfolded), Node id and the root write's Origin
  to node spans.
- solid-js exports diagnosticGuideUrl(code), shared with the console footer.
- Docs: 08-dev-diagnostics gains the timeline records, the ref-counted
  enable, the INP join recipe and a performance-tracks section; the plan
  lands as documentation/plans/chrome-performance-tracks-plan.md.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…aps for the tracks records

Measured against the branch base (49887e1) with the size scenarios:
prod core floor, + createStore, + isPending and frames byte-identical;
simple app -26 B, CSR -7 B, hydrating + stores -38 B, hydrating +40 B (two
`computed(fn, void 0)` trailing arguments the boundary names fold to; the
restructured alternatives measured larger under brotli).

- createStoreNext loses its `name` parameter - a parameter survives into
  the prod artifact; the public createStore records the name through
  nameStore (same attrHooks gate) under __OBSERVE__ instead.
- web spread's `.spread`/`.children` labels sit behind "_SOLID_OBSERVE_" so
  prod folds them to undefined rather than testing the argument at runtime.
- Observe tier 17.00 -> 17.15 KB (17,063 measured, +92 B: flushStart site,
  effectRun hooks under __OBSERVE__, flow-internal and binding names, store
  names). Observe + attribution 27.50 -> 28.30 KB (28,200 measured, +738 B:
  ref-counted enable, checks, hold verdicts, at/inputDelayMs, the flush /
  create / effect / flight / fallback records, nodeId on derived causes).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A <Loading> swap is a staged write that lands with its frame (#3575); the
engine held the fallback open from the swap and so timed — and, for a swap
the content outran or the commit's sweep cleared before any effect ran,
recorded — a fallback that was never on screen. The show now carries the
transaction the swap is staged in; the engine moves the open to the drain at
transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and
drops an open hidden before that. The folds hear show/hide from the same
gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…nert export, guarded host calls, owned clearing

- enablePerformanceTracks: one instance per page; a call while enabled joins
  it and returns its own release; torn down at the last release
- ./performance-tracks: node/worker/deno conditions resolve to the inert
  artifact under every posture (the tracks are a browser view)
- Emitter guards performance/console calls; a throw drops the entry and
  never reaches the engine's record loop (dev warns once)
- rich:false falls back to performance.measure when console.timeStamp is
  missing (the mirror of the existing fallback)
- rich mode clears a User Timing name only while the timeline holds no more
  of it than the adapter made; an app measure sharing a label is left alone
- signals: stagedFallbacks is a WeakMap keyed by transaction (DRAIN is an
  object key) so a dropped transaction releases its staged opens
- changeset: stale 'Scheduler track' wording -> Propagation
- docs: transformSourceNames is the compiler pass, sourceNames.primitives
  the Vite plugin option

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…per hold; dev component tasks only under an installed engine

Review items 5 and 6 on #3580.

- attribution.enable(opts) returns an idempotent release for the hold it
  takes. Options in effect = defaults with each live hold's opts layered in
  hold order, recomputed on release — a track's log:false beside a console
  session gives the log back when it leaves. disable() is the full teardown
  whatever holds are outstanding, so enable();enable();disable() cannot
  strand a hold. Prod twin: enable returns noop.
- performance-tracks releases through the token and layers log:false only
  when it is the consumer installing the engine (OBSERVE.attribution.installed
  === null); joining leaves a co-holder's options alone.
- solid-js dev: the component wrapper creates the console.createTask task
  only while an attribution engine is installed. The per-call stack capture
  roughly doubled dev mount (10k components 1.9 -> 4.1 ms) for sessions with
  nothing enabled; observe and prod builds never contained the call.
- Tests: layered options + release, disable teardown, adapter join vs
  install log default, task gating; docs and changesets updated.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…asserted against the commit stamp

- web test-types: the stubbed-API list is typed [object, string][] (the
  inferred (string | Console | Performance)[] failed tsc); getEntriesByName
  is restored with the rest
- signals: the shell-landed-first fallback test asserts at >= the landing
  stamp and shownMs >= the wait instead of a wall-clock upper bound, which a
  slow runner exceeded

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… token

capture.ts and the browser bridge release their own hold instead of calling
disable(), which under the token contract is the full teardown — a track
or APM adapter enabled beside a capture now survives it.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…emanding request

Replaces last-wins layering. Each hold's request is resolved against the
defaults (an explicit undefined is unsaid; checks:false folds its five
checks first), then combined per key: booleans OR, historyLimit max, a
threshold config over false, and between configs the bound that fires
sooner (lower count/budget/ms, longer windowMs). A hold adds to what the
engine does and never takes away what another asked for, so the result is
independent of the order holds were taken — a track enabled with log:false
beside a console session leaves its log alone in either order; a capture
with tight thresholds beside a records-only adapter runs the checks for its
own duration. Prior art: Node trace_events (union of every enabled
Tracing's categories), CDP per-session domain enablement.

- performance-tracks: always asks {log:false, ...options.attribution};
  the install-time heuristic is gone
- tests: order independence, checks while any holder wants them,
  explicit undefined; adapter beside a console session
- size: engine scenario 28.85 -> 29.00 KB (+194 B, engine-only), noted

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…3555

The merge with the new next measured 15,815 B against next's 15,764: the
same +51 B brotli name-assignment noise this branch has carried (minified
bundles 44,649 B on both sides), now on top of upstream bytes those PRs
left 36 B under the cap. No prod source changed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 24, 2026
…he correction

An optimistic override the screen displayed is replaced by a different
value on two roads: the guess lifts at settle back to the committed value
it covered (the action failed, or never wrote what it promised), or an
authoritative value lands that differs from it. Both are the runtime doing
what optimistic UI promises, so the finding is `info` and structured-channel
only — a count that grows for one source is what says the guess, or the
failure rate, is wrong. Judged by the node's own equality.

`AttributionHooks.optimisticReverted(el, shown, truth, how)` is the seam,
fired from supersedeOverride (how derived from whether a landing is staged)
and from the non-superseded drop in resolveOptimisticNodes. Not covered
yet, and said so: optimistic stores, and the interaction that wrote the
guess (optimistic writes bypass the write hook, so the node carries no
origin). The plan marks item 2 satisfied by #3580 — `at` equals the Event
Timing entry's `startTime`; `interactionId` is not readable at dispatch.

Size: engine +266 B, tier +38 B; the engine cap moved with a note.

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 24, 2026
…he correction

An optimistic override the screen displayed is replaced by a different
value on two roads: the guess lifts at settle back to the committed value
it covered (the action failed, or never wrote what it promised), or an
authoritative value lands that differs from it. Both are the runtime doing
what optimistic UI promises, so the finding is `info` and structured-channel
only — a count that grows for one source is what says the guess, or the
failure rate, is wrong. Judged by the node's own equality.

`AttributionHooks.optimisticReverted(el, shown, truth, how)` is the seam,
fired from supersedeOverride (how derived from whether a landing is staged)
and from the non-superseded drop in resolveOptimisticNodes. Not covered
yet, and said so: optimistic stores, and the interaction that wrote the
guess (optimistic writes bypass the write hook, so the node carries no
origin). The plan marks item 2 satisfied by #3580 — `at` equals the Event
Timing entry's `startTime`; `interactionId` is not readable at dispatch.

Size: engine +266 B, tier +38 B; the engine cap moved with a note.

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 24, 2026
…howed was replaced (#3607)

* feat(signals): OPTIMISTIC_REVERTED — the person saw the guess, then the correction

An optimistic override the screen displayed is replaced by a different
value on two roads: the guess lifts at settle back to the committed value
it covered (the action failed, or never wrote what it promised), or an
authoritative value lands that differs from it. Both are the runtime doing
what optimistic UI promises, so the finding is `info` and structured-channel
only — a count that grows for one source is what says the guess, or the
failure rate, is wrong. Judged by the node's own equality.

`AttributionHooks.optimisticReverted(el, shown, truth, how)` is the seam,
fired from supersedeOverride (how derived from whether a landing is staged)
and from the non-superseded drop in resolveOptimisticNodes. Not covered
yet, and said so: optimistic stores, and the interaction that wrote the
guess (optimistic writes bypass the write hook, so the node carries no
origin). The plan marks item 2 satisfied by #3580 — `at` equals the Event
Timing entry's `startTime`; `interactionId` is not readable at dispatch.

Size: engine +266 B, tier +38 B; the engine cap moved with a note.

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): re-measure OPTIMISTIC_REVERTED at landing; engine cap 31.45 -> 31.65 KB

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* fix(signals): OPTIMISTIC_REVERTED never judges the runtime's own optimistic nodes

The finding fired on every acknowledged hold: isPending()'s companion is an
optimistic signal that goes true while pending and back to false at commit
by design — the acknowledgement SILENT_HOLD asks for — so the repair for one
finding produced another. The check now skips companions and derived
overrides by the predicate the hold census already uses (`isCompanion`),
and `optimisticReverts: false` disables it like the other verdicts. The
regression test is the acknowledged hold itself (isPending and latest
readers over a held write): silent with the guard, one false finding
without it.

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): re-measure the OPTIMISTIC_REVERTED companion guard; engine 31,625 B under the 31.65 KB cap

The `optimisticReverts` option and the `isCompanion` skip in
checkOptimisticRevert are +35 B on the engine scenario (31,590 -> 31,625 B).
The cap holds; the note records the new measurement. Tier unchanged at 17,618.

Co-authored-by: Claude via Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants