Skip to content

feat(web,signals)!: "render" record + AttributionOptions.values; Server-Timing as record projections - #3650

Merged
ryansolid merged 1 commit into
nextfrom
render-record-scrub
Sep 25, 2026
Merged

ryansolid merged 1 commit into
nextfrom
render-record-scrub

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Was stacked on #3644 (records channel), which landed on next as 384a631; rebased onto next after #3645, #3646 and #3649 (235173e) and retargeted.

PR 3 of the public-API consolidation. Two legs, both closing the same gap: the observe surface had places where a fact was duplicated beside its record (the Server-Timing metrics kept their own clock and their own push) or scrubbed after the record was built (the performance-tracks adapter's scrub). Both now go through the record.

Leg A — the "render" record; Server-Timing strictly projected from records

New record. A server render (renderToString / renderToStream) is a record on OBSERVE.records, from @solidjs/web's server runtime:

interface RenderEvent {
  mode: "string" | "stream";
  at: number;              // performance.now() at render start
  shellMs?: number;        // start → shell complete; absent when the render ended pre-shell
  durationMs: number;      // start → the render's end
  boundaries: number;      // <Loading> boundaries the shell waited on (each a "boundary" record, streamed: false)
  outcome: "complete" | "abandoned" | "error";
}
interface RenderLive { event?: RequestEvent; trace: TraceContext }

Built by observeRender (server-observe.ts) under the one gate timesServerWork("render") = observed("render") || IS_DEV; the renderer holds a RenderObservation { shell(), boundary(), settle(outcome) }. shellMs is stamped where the shell actually completes — the stream's doShell (immediately before the sink handoff, so the head commit inside the sink's first write reads it) and the string render's assembled document. Settled at onDone (complete), in abandon (abandoned on a disconnect, error on a render failure), and in renderToString's finally. Delivered before the render's trace is cleared, so getTraceContext() in a listener finds it. Registered through the existing HostRecordTypes augmentation beside "invocation"; types exported from both @solidjs/web entries.

Header = projection. TraceRecord now holds record objects, not formatted metrics: timing: TimedWork[] ({ type: "invocation", event: InvocationEvent } | { type: "boundary", event: BoundaryEvent }) and render?: RenderEvent. appendTraceServerTiming projects at head commit — solid-shell from render.shellMs, then metricOf(work) per entry (solid-invocation ← durationMs/id; solid-boundary ← durationMs/ownerPath.join(" > ") else id). One code path builds each record; the header reads it. The solid-js boundary's seam changed from _timing.push({name,dur,desc}) to _recordBoundary(event: BoundaryEvent) — it builds its BoundaryEvent once (when timed and either a listener or the header wants it) and hands the same object to the seam and the channel.

Gates, one per metric: solid-invocation ↔ "invocation", solid-shell ↔ "render", solid-boundary ↔ "boundary". Dev: all three always. The web side installs the _recordBoundary seam under timesServerWork("boundary") — the same rule the boundary applies before building its record — so the two sides of one measurement agree by construction, and a mixed-tier harness (dev solid-js source under the observe @solidjs/web artifact, which is how server-trace.spec loads them) cannot make the header say what no listener asked for.

Wire unchanged. Same names, dur rounded to a tenth, desc quoted and ASCII-sanitised, owner path joined with > (full path, not shortened), solid-shell first then completion order. The pre-existing header assertions in server-trace.spec.tsx pass untouched; perf-tracks' navigation() and ServerSpans are not modified.

Leg B — AttributionOptions.values, applied at the source

One engine option, three monotone levels — a second axis was not needed:

Level ChangeRecord.prev/value, HeldWrite.prev/value ChangeOrigin.target / InteractionEvent.target / HoldEvent.interaction.target text Sentences (formatRerun, formatOrigin, SILENT_HOLD/LONG_HOLD, OPTIMISTIC_REVERTED)
"full" (dev default) present kept on every element quote previews and element text
"labels" absent kept only on button / a no 1 → 2; element text only for button/a
"none" (observe default) absent never names, numbers, kinds, outcomes only

Default is the build tier's: defaultOptions.values = __DEV__ ? "full" : "none" — folded at build time, so the observe engine artifact ships the literal "none" (pinned in dist-artifacts.test.ts). Applied where the record is built: stampWrite takes the previews only under "full" (so HeldWrite, copied from the ChangeRecord, and every sentence built on it follow); interactionStart copies InteractionRef.target through targetLabel() (cuts at the first ", the documented tag#id "text" shape — the ref itself is never mutated); checkOptimisticRevert quotes shown/truth (message and data) only under "full". Merge rule: least permissive wins (demanding() ranks none < labels < full). A holder that names no level asks for the tier's default — in observe that tightens to "none" beside anyone; a single holder passing "full" in observe gets "full"; an explicit "full" never loosens what another holder demanded; a release restores the remaining holders' level. Navigation to/from/params are the router's description and stay outside the option (documented).

perf-tracks lost: the scrub option, Painter.scrub, scrubRerun, scrubOrigin, scrubCause, scrubTarget, TARGET_TEXT, and the scrub branches in rootCause, heldWrite, origin, writes and the diagnostic tooltip. The adapter paints what the record carries; its observe artifact inherits the tier default "none"; enablePerformanceTracks({ attribution: { values: "labels" } }) reproduces the old observe posture exactly.

Public API changes

  • Added "render" record type on OBSERVE.records (HostRecordTypes augmentation in @solidjs/web); added exported types RenderEvent, RenderLive, RenderListener from @solidjs/web (client and server entries).
  • Added AttributionOptions.values?: "full" | "labels" | "none" and exported type AttributionValues from @solidjs/signals/attribution (and solid-js/attribution via export *), on the real and inert engines. Default is tier-dependent: "full" in dev builds, "none" in observe builds.
  • Removed PerformanceTracksOptions.scrub from @solidjs/web/performance-tracks. No compat alias (prerelease).
  • Behavior (observe tier): solid-shell now rides the "render" gate, not the "boundary" listener. A deployment subscribed to "boundary" alone keeps solid-boundary and needs a "render" subscription for solid-shell.
  • Behavior (observe tier): the attribution engine's records carry no value previews and no element text by default ("none"); a holder that wants them passes values explicitly. perf-tracks observe builds go from the old scrub posture (button/link labels kept) to "none"; a finding's marker/tooltip always carries event.message (a non-engine emitter's message is not the engine's to govern).
  • Behavior: OPTIMISTIC_REVERTED data is { source, how } without shown/truth under "labels"/"none"; message wording changes at those levels.
  • Internal (not public): solid-js server render-context seam _timing → _recordBoundary(event); @solidjs/web TimingMetric type → TimedWork (trace.ts, never exported).

Verification

  • pnpm build (18 package tasks), pnpm types clean.
  • @solidjs/signals: 229 files / 3708 tests pass — new attribution-values.test.ts (11: per level for ChangeRecord/HeldWrite/target/formatOrigin/formatRerun, SILENT_HOLD and OPTIMISTIC_REVERTED sentences and data, dev default, ref not mutated, three-holder merge with release, unnamed holder never loosens, level applies from its moment) and two tier pins in dist-artifacts.test.ts (dev artifact defaults "full"; observe artifact defaults "none", explicit "full" applies when alone, and the dev branch of the default is folded out of the artifact).
  • solid-js: 37 files / 781 tests pass; test-types clean.
  • @solidjs/web: client 102/943, server 112/1155 (+ server-trace.spec: per-gate observe-tier test — boundary listener alone → ["solid-boundary"], render alone → ["solid-shell"], both → both; a "render" record block pinning stream/string/no-scope/error/abandoned records, shell.dur === round(record.shellMs, 0.1), live.event/live.trace), hydrate 37/248; test-types (incl. augment) clean, with "render" added to observe.type-tests.ts.
  • @solidjs/diagnostics: 6/34 pass (unchanged — its types.ts still carries hand-written BoundaryRecord/InvocationRecord mirrors on this base, so no RenderRecord was added; see follow-ups).
  • Leftover grep clean: no scrub (adapter), _timing, shellStart, TimingMetric, timesServerWork("boundary") outside timeDocument.
  • Size (scripts/size, head vs records-channel built in a detached temp worktree): every prod scenario and the observe tier byte-identical (0 B). app: CSR, observe tier + attribution engine enabled: 31,689 → 31,868 B (+179 B), cap 31.70 → 31.90 KB with a note. That is the values machinery — targetLabel, the "full" gates in stampWrite/checkOptimisticRevert, VALUES_RANK + the demanding branch — a deliberate feature.

Judgment calls

  • Middle level named "labels" rather than "previews": under "full" the values are already previews, so "previews" would name the thing being removed. "labels" names what is kept (the control's caption).
  • shellMs is stamped at the shell's own completion point, not at stub commit. For a stream that is equivalent to before (commit happens inside the shell's first sink write); for an awaited renderToStream whose head is committed at completion, solid-shell now measures the shell rather than the whole render — durationMs carries the whole render.
  • RenderEvent.boundaries counts the boundaries the render filed through the seam, i.e. under the "boundary" gate: in observe with a "render" listener alone it is 0 (documented on the field). Counting independently would need the boundary to call the seam without a record — a second path for one fact.
  • Two pre-existing unreleased changesets (performance-tracks-adapter.md, performance-tracks-dev-enrichments.md) still describe the observe scrub. Left untouched; the new changeset states the removal.

Follow-ups

  • Sentry (getsentry/sentry-javascript#24517): map targetText: true ↦ values: "full" (most permissive; false ↦ "none", which is now also the observe default), drop its local regex scrub of target, and subscribe to "render" for the SSR shell span (solid-shell no longer arrives on the "boundary" listener alone).
  • Consider dropping the performance-tracks observe-tier artifact: it is a dev tool in practice, and observe should stay tight; with values defaulting to "none" there its tracks carry little a developer wants anyway.
  • @solidjs/diagnostics: once PR 2 lands the generic server-record listing, add "render" to the artifact; on this base the package still hand-mirrors BoundaryRecord/InvocationRecord, so nothing was added here.
  • solid-docs pass: values levels + tier default + merge rule, the "render" record, enablePerformanceTracks options (no scrub), the per-record Server-Timing gates.

Rebase notes

Rebased the single render-record-scrub commit (94b0623b0) onto next at 235173e6a (git rebase --onto origin/next 94b0623b0^; the branch sat on the older records-channel tip 255f8e063). Four conflicts, all mechanical:

Re-verified on the new base: signals 229 files / 3711 (+2 skipped) incl. attribution-values.test.ts 11; solid 781, test-types clean; web client 960 / server 1168 (+2 skipped) / hydrate 248, test-types (incl. augment) clean; diagnostics 34. (Relative to next: client −1 — two scrub tests replaced by one values test — and server +5, the server-trace.spec additions; the Verification section's client "943" was PR 2's count.) pnpm build --filter './packages/*' 17/17, pnpm types clean. @solidjs/diagnostics was left without a "render" row on purpose (follow-up below).

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 92bc67b

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
test-integration Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler 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 changed the base branch from records-channel to next September 25, 2026 06:43
@coveralls

coveralls commented Sep 25, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 36104867710

Coverage remained the same at 73.611%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1134
Covered Lines: 883
Line Coverage: 77.87%
Relevant Branches: 882
Covered Branches: 601
Branch Coverage: 68.14%
Branches in Coverage %: Yes
Coverage Strength: 25.51 hits per line

💛 - Coveralls

…er-Timing as record projections

Leg A: a server render is a "render" record on OBSERVE.records
(RenderEvent: mode, at, shellMs, durationMs, boundaries, outcome; live
event + trace). The Server-Timing metrics are now strict projections of
records under one gate each: solid-invocation <- "invocation", solid-shell
<- "render", solid-boundary <- "boundary" (the solid-js boundary files its
BoundaryEvent through `_recordBoundary`). Wire format unchanged.

Leg B: AttributionOptions.values ("full" | "labels" | "none") governs the
user-data fields of the engine's records at the source; least permissive
level wins across holds; default is the tier's (dev "full", observe
"none", folded at build time). performance-tracks' `scrub` option and
helpers are removed — the adapter paints what the record carries.

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

codspeed Bot commented Sep 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing render-record-scrub (92bc67b) with next (3613c3a)

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
ryansolid merged commit dd53561 into next Sep 25, 2026
7 checks passed
@ryansolid
ryansolid deleted the render-record-scrub branch September 25, 2026 07:18
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