Skip to content

feat(runtime)!: preview on server and commit in browser - #535

Merged
Charles Hudson (phobetron) merged 29 commits into
mainfrom
NT-4307_paired-replay-redux
Oct 13, 2026
Merged

Charles Hudson (phobetron) merged 29 commits into
mainfrom
NT-4307_paired-replay-redux

Conversation

@phobetron

@phobetron Charles Hudson (phobetron) commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Why and behavior

Previously, the server could commit the first page event while rendering and tell the browser to emit or skip it. This change makes the server responsible for preparation and preview, and the browser responsible for event commitment.

  • The server prepares ordered identify/track/page events and previews them with per-request preflight: true. Preview data can select server-rendered content; a recoverable preview failure keeps the replay available while the page can render baseline content.
  • The browser hydrates the handoff and attempts to admit a matching replay as one batch through the existing Web SDK Experience queue. Current consent, route, and queue capacity still apply. If validation or admission rejects the replay, the browser attempts one ordinary current-page event through its existing guards and returns that result. Admission is distinct from HTTP delivery and does not promise exactly-once backend commitment after an ambiguous response.
  • Profile IDs come from the Experience API or an existing cookie. Next.js App/Pages and Edge adapters, Angular, and React/Web consumers preserve cookie continuity and track later navigation normally. Static/ISR public handoffs keep app-selected state without private visitor replay.
  • Core, Web, React, and Next.js tests cover preparation, preview failure, queue delivery, and route handling. Maintained reference implementations and E2E assertions exercise the consumer paths; native SDK configuration drops the obsolete global preflight field.

Delivery sequence and failure boundaries

sequenceDiagram
  participant Server as Server request
  participant API as Experience API
  participant Browser as Browser SDK
  participant Queue as Experience queue
  Note over Server,Browser: routeKey = visible pathname + search
  Server->>Server: Prepare identify/track + page
  alt Effective page gate permits preview
    Server->>API: Upsert prepared events, preflight=true
    alt Preview succeeds
      API-->>Server: Selected state + API-issued profile ID
      Server->>Server: Render personalized HTML
    else Preview fails
      API-->>Server: Preview error
      Server->>Server: Render baseline and retain prepared replay
    end
  else Page event blocked
    Server->>Server: Render baseline with no replay
  end
  Server-->>Browser: HTML + private handoff (state and/or replay)
  Browser->>Browser: Validate handoff and publish continuation identity
  Browser->>Browser: Hydrate current state and compare current route
  alt Matching prepared replay
    Browser->>Queue: Recheck consent and admit whole batch
    alt Queue admits batch
      Queue-->>Browser: accepted = local admission
      Queue->>API: Deliver batch, preflight=false
      API-->>Queue: Delivery response
      Queue-->>Browser: Publish state if lifecycle and profile revision remain current
    else Validation or admission rejects batch
      Queue-->>Browser: Not admitted
      Browser->>Browser: Verify initialization is still current
      alt Initialization remains current
        Browser->>Queue: Attempt ordinary current page through normal guards
        Queue-->>Browser: Ordinary page result
      else Superseded, reset, or destroyed
        Browser->>Browser: Return accepted=false without stale fallback
      end
    end
  else No replay or route mismatch
    Browser->>Queue: Track ordinary current page
  end
  Note over Browser,Queue: Accepted replay admission is not HTTP completion. Delivery failure never starts fallback
Loading

Preview determines first paint without committing the paired events. The browser is the paired batch's submitter; a preview failure can still leave useful baseline HTML and a replay for later delivery. A lost delivery response leaves backend commitment ambiguous, so retries do not guarantee exactly-once delivery.

Queue admission, flush completion, and identity

  • Concurrent flush() calls join the active drain for each queue, including forced calls. Public CoreStateful.flush() joins both Experience and Insights attempts. Completion means the attempt settled; it does not guarantee delivery or wait for scheduled retries. Force bypasses connectivity/backoff/circuit gates, never the concurrency guard.
  • Offline Experience admission preserves previously accepted events in FIFO order. A full queue rejects an incoming ordinary event with { accepted: false }; a prepared batch is admitted whole or rejected whole. Capacity defaults to 100 events and remains configurable. onOfflineDrop, its context type, and native callback declarations remain as deprecated, inactive compatibility interfaces; rejection is not reported as eviction.
  • Prepared handoffs have a fixed SDK limit of 100 total events, including exactly one final page, with at most 50 identify events. Identify/track events precede that page. Validation runs before server preflight and browser admission; larger configured queues do not raise this limit, and smaller or partially occupied queues can reject an otherwise valid batch. No automatic splitting or truncation is introduced, and general API schemas retain their existing limits.
  • Web treats prepared-event validation failures and { accepted: false } admission results as an ordinary current-page fallback. It retains adopted identity and applicable hydrated state, uses the caller’s buildPayload or the default payload, and returns the ordinary attempt’s result. Deduplication, consent, queue capacity, and lifecycle guards remain authoritative; no capacity is reserved and no accepted work is evicted. Repeated current initialization joins the fallback. Supersession, reset, or destroy suppresses stale fallback; an accepted replay never falls back after an HTTP failure. Envelope/cache-safety failures and unrelated programming or ordinary transport errors retain their existing behavior.
  • A still-current initialization selects and publishes continuation identity synchronously at the beginning of its attempt. Existing live/adopted continuity wins over an incoming candidate. An initialization superseded before starting adopts nothing; navigation retains identity already published to other operations while suppressing obsolete pending hydration/replay. Already accepted work remains queued. Evaluated profile-state hydration and explicit reset/destroy boundaries retain their existing semantics.
  • Prepared delivery retains event bodies, order, IDs, timestamps, captured locale, and target identity across retries, subject to the existing consent transformation. Ordinary events and handoff admission do not wait for replay HTTP completion. Identity behavior is preserved and covered by focused regressions, including an ordinary event interleaved between two handoffs.

Attempt ownership and overlapping response publication

  • Current-page/screen tracking retains configurable key equality for duplicate suppression and uses a distinct object for each in-flight attempt. Completion can mark acceptance or clear the slot only while that exact attempt owns it. After reset, an older /a completion cannot accept or clear a new /a attempt; refusal or failure of the new attempt leaves a subsequent attempt possible.
  • Request-scoped previews and ordinary events capture a profile revision before asynchronous preparation or event interception. They publish profile and selections together only if no intervening publication changed the revision. Stale responses still return their data, acceptance result, and prepared handoff where applicable.
  • Browser/stateful Experience delivery checks the captured revision alongside existing lifecycle guards after state interceptors. Shared profile writes invalidate older publication rights, and every permitted response publication advances the revision even when the profile is deeply equal, preventing stale selections from replacing newer ones. Accepted delivery, queue draining, retries, and teardown behavior remain independent of publication rights.
  • Requests remain concurrent. Event-locale versus content/request-locale handling, consent, visibility thresholds, and entity policies are unchanged. Eleven focused regressions cover reset ownership, delayed preview/event responses, both interceptor boundaries, equal-profile selection changes, normal publication, and accepted prepared delivery without redelivery.

Destroy-time best-effort delivery

CoreStateful.destroy() can continue an active Experience flush or force one flush of queued prepared batches. The drain attempts batches sequentially with their original events, target, locale, and order. It can carry an API-returned profile ID to a later batch without publishing late profile or request state. Destroy remains synchronous, rejects new submissions, and cancels retry timers; reset() still cancels immediately. A failed request may stop the drain, so this remains best-effort delivery rather than durable or exactly-once delivery.

The measured gzip sizes required four budget updates under the repository rule (measured bytes + 100, rounded up to the next 100): Core index.cjs 25,300 and index.mjs 24,500; Web UMD 38,900 and Web Components UMD 44,400. No other bundle budgets changed.

Public API and option changes

Surface Input or result Consumer contract
Core request prepareRequestHandoff({ routeKey, initialEvents?, page? }) routeKey is the visible pathname plus search, starts with /, and excludes origin and hash. It must match the browser route exactly for replay. initialEvents accepts ordered identify and track inputs only; consent-allowed inputs precede the generated page. The prepared batch permits at most 100 events including the page and at most 50 identifies; invalid or oversized input is rejected before preflight. page optionally supplies page builder args, with request event context as the default.
Core result { handoff, data? } handoff is private-request state with optional profileId and replay (routeKey, prepared events, locale). data exists on a successful preview. A preview error retains replay without data; a blocked page or invalid input has no replay. IDs come from an existing profile or the API, not SDK generation.
Web browser hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload? }) A matching replay attempts admission as one queued batch. Missing, mismatched, or rejected replay attempts ordinary current-page tracking using lazy buildPayload or the default payload. Accepted replay settles at local queue admission; fallback returns the ordinary page result, which can await online HTTP delivery. If both attempts are refused, the result is { accepted: false }. An accepted fallback completes shared initialization. Use trackCurrentPage({ routeKey, buildPayload }) for later navigation.
Stateful queues flush(), queuePolicy.offlineMaxEvents, EventEmissionResult.accepted Concurrent callers join the active attempt. Offline work is admitted whole or rejected without evicting earlier work. The default capacity is 100; onOfflineDrop is deprecated and inactive.
Consent Request consent, SDK allowedEventTypes, browser consent/persistence Event admission permits accepted consent or an allowed event type; Node defaults allow identify and page before consent, so strict opt-in requires allowedEventTypes: []. Browser replay rechecks current event policy; profile-cookie persistence is a separate decision.
API client Mutation preflight? preflight belongs to single-profile mutation request options, not constructor or SDK-wide api config. true previews; omitted or false is normal mutation delivery. Preparation sends true and browser replay sends false. A preview can return an API-issued ID without committing profile state.
Next.js App Router request.initialEvents?, request.pagePayload? Each accepts a value or sync/async resolver receiving { requestUrl, routeKey, cookies, headers }. The request family resolves inputs once and derives a visible pathname/search key (excluding Next.js transport _rsc); the page is appended after initial events. hydration remains a separate rendering choice.
Next.js Pages Router createRequestHandoff(context, { initialEvents?, pagePayload?, hydration, ... }) The helper derives the route key from resolvedUrl and previews instead of committing. Pass its returned handoff to the browser root and tracker; no app-supplied routeKey option is needed here.
Next.js low-level helper createNextjsRequestHandoff() result Returns { data?, handoff, requestOptimization } instead of pageResult; hydration is required and request cache scope is private. Standalone getNextjsServerOptimizationData() remains the direct server-event path.
Next.js Edge createEdgeRequestHandoff({ request, initialEvents?, pagePayload?, hydration, ... }) The key comes from request.url pathname/search. The result exposes data (possibly undefined), handoff, requestOptimization, and persist(response); pageResult is removed. Request handoffs remain private-request; call persist(response) to apply the cookie write or clear under the configured persistence policy.
App Router handler Bound requestHandler / createNextjsOptimizationContextHandler() The handler forwards sanitized request context and can bind existing consent/identity, but does not submit a page event. trustedRequestHandoff and forwarded server-commit data are removed.
React and Next.js tracking Tracker handoff?; provider/root routeKey?; root beforeInitialPage Pass the same handoff to a React/Pages root and tracker; the App Router request wrapper supplies handoff and route key automatically. React Router/TanStack use pathname plus search (not hash) when matching handoff replay. Next.js tracker initialPageEvent is removed. A root with prepared replay bypasses beforeInitialPage; a browser-owned callback root without replay requires routeKey and lazy buildPagePayload, with no separate tracker.
Analytics-only runtime hydrateAndTrackCurrentPage / hydrateOptimizationAnalyticsHandoff The runtime exposes the shared admission method. The analytics helper still requires routeKey and lazy buildPagePayload, with optional isCurrent to stop stale hydration; it has no browser content-resolution APIs.
Public/static handoffs Selection helpers, cache scope, hydration initialPageEvent is removed. Public/static handoffs carry caller-supplied selections, not request profileId or replay; cache safety rejects private state in shared scopes. Hydration mode controls presentation, not event ownership.
Cookie and native config Next.js cookie; native OptimizationApiConfig.preflight / bridge api.preflight Next.js cookie config now shares Web attributes (domain, expires, path, sameSite, secure); the profile cookie stays browser-readable when persisted. Global preflight is removed from the Swift, Android, and React Native bridge configs.

Consumer DX: before and after

The examples show the integration paths changed by this branch. App-owned helpers and values are abbreviated.

Manual Node + Web SSR

Before: The server sent the page event and passed its resulting state to the browser.

const pageResult = await requestOptimization.page()
const data = pageResult.accepted ? pageResult.data : undefined

After: The server previews a prepared batch for rendering; the browser commits that batch for the matching route.

// Server
const { data, handoff } = await requestOptimization.prepareRequestHandoff({ routeKey })
// Render with data and serialize handoff to the browser.
// Browser
await web.hydrateAndTrackCurrentPage(handoff, { routeKey })
Angular SSR

Before: Browser hydration consumed state plus an emit/skip instruction.

hydrateOptimizationHandoff(sdk, {
  cache: { scope: 'private-request' },
  hydration: 'preserve-server',
  initialPageEvent: snapshot.consent ? 'skip' : 'emit',
  state: snapshot.data,
})

After: Angular transfers the prepared handoff alongside the SSR snapshot and admits its events through Web SDK delivery.

if (!snapshot?.handoff) return
await sdk.hydrateAndTrackCurrentPage(
  { ...snapshot.handoff, state: snapshot.data },
  { routeKey: `${window.location.pathname}${window.location.search}` },
)
React Web router integration

Before: The root had a handoff, but the router tracker had no handoff input.

<OptimizationRoot handoff={handoff}>
  <ReactRouterAutoPageTracker />
</OptimizationRoot>

After: Root and tracker share the handoff, so the matching first-route replay is admitted together and later routes use ordinary tracking.

<OptimizationRoot handoff={handoff}>
  <ReactRouterAutoPageTracker handoff={handoff} />
</OptimizationRoot>
Next.js App Router

Before: Before-page browser work required a callback-bound root injected into the server request family.

bindNextjsAppRouterServerOptimization(config, {
  request: { OptimizationRoot: ClientRequestOptimizationRoot },
})

After: Request-scoped initial events are prepared with the server page preview; the request tracker receives the handoff automatically.

bindNextjsAppRouterServerOptimization({
  ...config,
  request: {
    initialEvents: ({ cookies }) => {
      const userId = cookies.get('user-id')?.value
      return userId ? [{ type: 'identify' as const, userId }] : []
    },
  },
})
Next.js Pages Router

Before: The server committed the first page and the browser tracker received an emit/skip instruction.

const handoff = await createRequestHandoff(context, { hydration: 'preserve-server' })
<OptimizationRoot handoff={handoff}>
  <NextPagesAutoPageTracker initialPageEvent={handoff ? 'skip' : 'emit'} />
</OptimizationRoot>

After: The helper previews prepared events, and root and tracker share the handoff for browser queue commitment.

const handoff = await createRequestHandoff(context, {
  hydration: 'preserve-server',
  initialEvents: userId ? [{ type: 'identify', userId }] : undefined,
})
<OptimizationRoot handoff={handoff}>
  <NextPagesAutoPageTracker handoff={handoff} />
</OptimizationRoot>
Next.js Edge request

Before: Consumers observed a server page result and its browser skip signal.

const { handoff, pageResult, persist } = await createEdgeRequestHandoff(options)
const response = await renderEdgePage({ handoff, accepted: pageResult.accepted })
persist(response)

After: Edge previews render data and returns prepared replay in the private handoff; the browser commits it after hydration.

const { data, handoff, persist } = await createEdgeRequestHandoff(options)
const response = await renderEdgePage({ data, handoff })
persist(response)
Static and ISR public permutations

Before: A public selection handoff needed an explicit first-page emit instruction.

createPublicPermutationHandoff({
  permutationKey,
  selectedOptimizations,
  hydration: 'preserve-server',
  initialPageEvent: 'emit',
})

After: Public handoffs carry selection state without visitor replay; normal browser tracking owns the page.

createPublicPermutationHandoff({
  permutationKey,
  selectedOptimizations,
  hydration: 'preserve-server',
})
React Native, iOS, and Android configuration

React Native's maintained integration moves SDK ownership into its root:

// Before
<OptimizationRoot instance={sdk}><Dashboard /></OptimizationRoot>

// After
<OptimizationRoot {...optimizationConfig} defaults={{ consent: true, persistenceConsent: true }}>
  <Dashboard />
</OptimizationRoot>

Global preflight configuration is removed from native APIs; server request APIs own preview instead:

// Before
OptimizationApiConfig(preflight: true)
// After
OptimizationApiConfig()
// Before
OptimizationApiConfig(preflight = true)
// After
OptimizationApiConfig()

Lines changed

pie showData
  title Changed lines by category (added + deleted)
  "Production code" : 3189
  "Comments" : 269
  "Unit test code" : 4739
  "E2E test code" : 1086
  "Reference implementations" : 1561
  "Documentation" : 2490
  "CI" : 29
Loading
Category Added Deleted Total churn
Production code 1,738 1,451 3,189
Comments 153 116 269
Unit test code 3,374 1,365 4,739
E2E test code 473 613 1,086
Reference implementations 963 598 1,561
Documentation 1,523 967 2,490
CI 17 12 29
Total 8,241 5,122 13,363

Counts cover the full PR using git diff origin/main...HEAD at ec4ef8ba. Churn includes blank lines. Markdown is documentation; .github/ is CI; remaining files under implementations/ are reference implementations. Full-line comment markers (//, /*, *, #, <!--) elsewhere are comments. Remaining E2E paths and test files are counted separately; other source, configuration, and tooling are production code.

Validation

Validation of the current changes:

  • pnpm --filter './packages/**' typecheck and pnpm --filter './packages/**' test:unit: passed; 1,341 tests across API Client, Core, API Schemas, Node, Web, React Native, the native JS bridge, React Web, and Next.js. Core has 422 tests and Web has 353, including the 11 new regressions.
  • pnpm lint, pnpm format:check, and pnpm knowledge:check: passed. Lint reports two existing deprecation warnings. Commit hooks passed; push hooks reran Core/Web typechecks and unit suites successfully.
  • pnpm build:pkgs, pnpm size:report, and pnpm size:check: passed across the package graph. No bundle budget changes were needed for the runtime correctness fixes.
  • After refreshing installed local SDK tarballs, pnpm test:e2e:web-sdk navigation.spec.ts live-updates.spec.ts offline-queue-recovery.spec.ts: 30 passed, 6 skipped across Chromium, Firefox, and WebKit. The skips are preview-panel cases disabled by the build configuration.
  • pnpm test:e2e:node-sdk+web-sdk displays-identified-user-variants.spec.ts offline-admission.spec.ts: 24 passed across the three browsers, covering prepared replay, cookie/profile continuity, delayed/failed delivery, capacity rejection, FIFO order, and concurrent flush completion.

Remove constructor-level preflight configuration and retain preview only on per-request single-profile mutations. Evaluate preview responses without persisting mock profile state.
Prepare request handoffs with API-issued profile IDs and retain atomic replay batches in the existing Experience queue. Share reset invalidation across stateful queues and preserve events appended during delivery. Remove ordinary preflight controls, record the accepted phase plan, and apply the approved measured-size budget formula.
@bito-code-review

bito-code-review Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Bito Automatic Review Skipped - Large PR

Bito didn't auto-review this change because the pull request exceeded the line limit. No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.

@wiz-inc-38d59fb8d7

wiz-inc-38d59fb8d7 Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 14 Low
Software Management Finding Software Management Findings -
Total 14 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

@phobetron Charles Hudson (phobetron) changed the title feat: preview server requests and commit paired events in browser feat(runtime)!: preview on server and commit in browser Oct 8, 2026
@phobetron
Charles Hudson (phobetron) merged commit 4169fb0 into main Oct 13, 2026
46 checks passed
@phobetron
Charles Hudson (phobetron) deleted the NT-4307_paired-replay-redux branch October 13, 2026 07:49
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.

1 participant