Repository navigation
feat(core): pair server previews with browser commits - #524
Closed
Charles Hudson (phobetron) wants to merge 1 commit into
Closed
Charles Hudson (phobetron) wants to merge 1 commit into
Charles Hudson (phobetron) wants to merge 1 commit into
Conversation
|
Bito Automatic Review Skipped - Large PR |
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
Charles Hudson (phobetron)
force-pushed
the
NT-4307_paired-replay
branch
6 times, most recently
from
October 1, 2026 09:09
07d2826 to
2b3d47d
Compare
Build Personalization and Analytics events once on the server, preflight one Personalization batch, and carry the wire events and provisional state in a private handoff. Commit from the browser through normal consent, interceptor, queue, state, and persistence policy without regenerating event identity or metadata. Combine hydration and the initial replay/page decision, preserve admitted journals when newer handoffs arrive, and render preview content independently of delivery. Keep superseded options and methods deprecated and inert, restrict preflight transport to single-profile mutations, and update Next.js, Angular, Node plus Web integrations, documentation, and coverage together. Normalize all configured release bundle gzip budgets to the fresh measured size rounded to the nearest hundred bytes, plus one hundred bytes. Synchronize the Pages Router watchdog E2E with the timeout diagnostic before releasing identify, since newer handoff page delivery is independent.
Charles Hudson (phobetron)
force-pushed
the
NT-4307_paired-replay
branch
from
October 1, 2026 11:41
2b3d47d to
7caedc6
Compare
Charles Hudson (phobetron)
marked this pull request as draft
October 5, 2026 08:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tracking:
NT-4307Private SSR and Edge personalization previously committed events on the server and suppressed the browser page. This PR evaluates one server preflight for rendering, then commits the retained events from the browser so hybrid profile mutations and durable continuity have one browser owner.
experienceandinsightswire-event arrays, retaining IDs, timestamps, channel, library metadata, and request context.previewInitialExperience()appends the initial page;previewExperience()also supports journals without a page and Analytics-only journals with an existing profile. Ordinary Node event methods continue to commit on the server.hydrateAndTrackCurrentPage()owns hydration, replay admission, and ordinary-page fallback. Live consent, ordinary browser interceptors, validation, queue capacity, and retries still apply; browser rebuilding is removed.System implemented by this PR
The diagram shows a private, page-bearing SSR request. Public/static handoffs carry selections without replay. Analytics-only rendering uses the same delivery owner without installing content-resolution context.
sequenceDiagram participant App as Application participant Server as Node / Next.js / Angular SSR participant Experience as Experience API participant Browser as Browser SDK participant Insights as Insights API App->>Server: Request context, consent, existing profile ID, event inputs Server->>Server: Build wire events and run server interceptors once Server->>Experience: One Personalization batch, type=preflight Experience-->>Server: Provisional profile, selections, and changes Server-->>App: Personalized HTML + private handoff with state and wire arrays App-->>Browser: Deliver handoff through existing SSR data channel Browser->>Browser: Hydrate memory and publish state readiness Note over App,Browser: Preview rendering does not wait for event delivery Browser->>Browser: Match route, check live consent, run ordinary queue interceptors alt Online and accepted replay page Browser->>Experience: Commit retained Personalization events, normal POST Experience-->>Browser: Live profile, selections, and changes Browser->>Browser: Apply live state and persist when persistence consent permits else Offline and queue admits replay Browser->>Browser: Queue events under existing capacity/retry policy Note over Browser,Experience: Durable continuity waits for a successful live response else No accepted replay page Browser->>Browser: Run browser prerequisite work Browser->>Experience: Ordinary page attempt when consent permits Experience-->>Browser: Live response Browser->>Browser: Apply state and persist when permitted end Browser->>Insights: Queue/flush retained Analytics with available profile Note over Server,Browser: Event IDs and server-origin metadata are retained Note over Browser: New handoffs preserve earlier admitted journalsIntegration and compatibility
Next.js App Router request resources, Pages Router helpers, Edge handoffs, Angular, and Node/Web integrations use the paired flow. The Next.js request-context handler only forwards sanitized request context. Framework roots use the combined initial operation and ordinary later-route tracking, without a separate event-completion presentation gate.
Existing server input APIs remain usable; input dispatch stays on the server. The unmerged serialized replay contract uses wire arrays instead of semantic commands. SDK adapters, maintained consumers, package guidance, guides, and verified SDK facts are updated together.
Global/stateful
api.preflight,initialPageEvent,trustedRequestHandoff, obsolete context-handler inputs, and Edgepersist()remain deprecated and inert. Low-level preflight applies only to supported single-profile mutations. Direct Node/Web deployments must coordinate server preview with the browser SDK that consumes the handoff. Without JavaScript, previewed HTML can render but no browser commit occurs.Consumer migration: before → after
Each red/green diff is a visual comparison: removed lines show the base branch's pattern, added lines show the new pattern. These are adaptation fragments using app-owned configuration, renderers, consent policy, and request values. Runtime source compatibility does not imply unchanged behavior. The migration requirement is stated for each path.
The focused handoff-factory typing fix is included in this branch. Its after examples select
hydrationdirectly in Core/Node factories and receive the corresponding literal return type, without a spread wrapper or assertion. Existing Pages Router and Edge helper APIs are unchanged; their broad result unions can still require a content/analytics type guard. Compile contracts reject invalid hydration strings.1. Manual Node SSR + vanilla Web — migration required for server-commit/browser-skip flows
Move the initial mutation to browser ownership. Transfer an accepted preview, or a typed empty private fallback, through the app's existing SSR channel. Use the preview data for server rendering; persist identity from live browser responses rather than preview data.
Server:
import { createRequestHandoffFromData, + createRequestHandoffFromPreview, } from '@contentful/optimization-node' -await requestOptimization.identify({ userId, traits }) -const result = await requestOptimization.page({ properties: { url: pageUrl } }) -const handoff = result.accepted - ? { - ...createRequestHandoffFromData({ data: result.data }), - hydration: 'preserve-server', - initialPageEvent: 'skip', - } - : undefined +const preview = await requestOptimization.previewInitialExperience({ + events: [{ type: 'identify', userId, traits }], + page: { properties: { url: pageUrl } }, +}) +const handoff = preview.accepted + ? createRequestHandoffFromPreview({ preview, routeKey, hydration: 'preserve-server' }) + : createRequestHandoffFromData({ hydration: 'preserve-server' })Browser:
2. React Web with a custom SSR handoff — change the server flow; remove suppression ownership
The browser root remains the delivery owner. The handoff must come from the preview/fallback factories above; deleting a skip flag while continuing server commits can double-count events.
<OptimizationRoot {...clientConfig} - handoff={committedServerHandoff} + handoff={previewHandoff} routeKey={routeKey} buildPagePayload={() => currentPagePayload} onStatesReady={subscribeToEvents} > - <AutoPageTracker initialPageEvent="skip" /> <PersonalizedPage /> </OptimizationRoot>AutoPageTrackerabove denotes the app's separately mounted router tracker, not a new SDK export. If a separate tracker was not mounted, omit that removal. One handoff/callback-owning root handles initial and later pages.beforeInitialPagegates the fallback page attempt, while preview content can render independently.3. Angular SSR — transfer preview handoff; publish the live runtime before delivery finishes
Update the service's server transfer object and browser setup. App-owned consent and entry rendering remain in place.
The cookie writer shown on the removed line is app code. The existing Angular router subscription continues calling
trackCurrentPage().4. Next.js App Router — move proxy policy into the request binding
If consent/SDK work was configured on the context handler, move it into the server request binding. Existing request-root injection and request-family components remain in place. A binding already configured this way may need no source edits, but server preview/cookie semantics still change.
The
initialExperienceEventsaddition is optional; use it only for events that belong in the initial batch. The client root is still obtained frombindNextjsAppRouterClientOptimization(clientConfig).RequestOptimizationRoot, and the server layout still mountsoptimization.request.OptimizationRootand requestOptimizedEntrycomponents. Do not add a second request tracker.5. Next.js Pages Router — remove server cookie-write options and migrate extra initial events
The helper name and basic root wiring remain usable. Its operation changes from server commit to preflight; it no longer writes the server profile cookie. Removed cookie-write options are a source migration for callers that supplied them.
// Server helper bound from /pages-router/server: const handoff = await createRequestHandoff(context, { hydration: 'preserve-server', cache: { scope: 'private-request' }, pagePayload: currentPagePayload, - cookieOptions: { path: '/', sameSite: 'lax' }, - deleteWhenProfileCannotPersist: true, + initialExperienceEvents: [{ type: 'identify', userId, traits }], }) +if (handoff.hydration === 'analytics-only') throw new Error('Expected content handoff') return { props: { contentfulOptimization: { handoff } } } // pages/_app.tsx: the bound root remains the route owner. <OptimizationRoot handoff={pageProps.contentfulOptimization?.handoff} routeKey={router.asPath || router.pathname} buildPagePayload={() => currentPagePayload} > - <NextPagesAutoPageTracker initialPageEvent="skip" /> <Component {...pageProps} /> </OptimizationRoot>The identify input is optional and replaces a separate initial server identify when browser commit is intended. Remove the tracker only if it was mounted beside the handoff/callback-owning root. Configure browser continuity through the existing browser/root policy rather than removed per-request server-cookie options.
6. Next.js Edge — stop persisting preview identity from the response
The helper continues returning a handoff, but
persist()becomes inert. Selecting the mode inline needs no literal assertion. The existing helper still has a broad return type, so narrow the result before passing it to a content-only root.The initial track input is optional.
renderPrivateResponse()denotes the app's renderer/serialization layer. Preview state can render with JavaScript disabled, but the browser cannot commit events or establish new continuity in that case.7. Static/public permutation and analytics-only pages — review first-page policy
An old
emitsetting can be removed with the same page-delivery intent. An oldskipsetting is a behavior migration: the update emits an ordinary browser page when consent and deduplication admit it; there is no equivalent skip policy in the new flow.const handoff = optimization.createPublicPermutationHandoff({ selectedOptimizations, permutationKey: segmentKey, cacheVersion, locale, entryIds, hydration: 'preserve-server', - initialPageEvent: 'emit', }) <optimization.OptimizationRoot handoff={handoff} routeKey={routeKey}> <optimization.OptimizedEntry baselineEntry={entry} liveUpdates={false}> {renderEntry} </optimization.OptimizedEntry> </optimization.OptimizationRoot>// Analytics-only describes rendering ownership; page delivery still uses Personalization. const analyticsHandoff = optimization.createHandoffFromSelections({ cache: { scope: 'static' }, selectedOptimizations, hydration: 'analytics-only', - initialPageEvent: 'emit', }) <optimization.OptimizationAnalyticsRoot handoff={analyticsHandoff} routeKey={routeKey}> {serverRenderedMarkup} </optimization.OptimizationAnalyticsRoot>Existing selections and server-rendered markup remain app-owned; public/static handoffs never acquire private replay. Non-React analytics owners still use
initializeOptimizationAnalyticsRuntime()andhydrateOptimizationAnalyticsHandoff().8. Client-only Web, React Native, iOS, and Android — review use of global preflight
If live committing events were intended, remove the obsolete flag as shown. If preview-only behavior was intended, merely removing the flag is not an equivalent migration: stateful SDK events now commit, and those consumers need an explicit supported preview integration or the old behavior retained for compatibility. Callers that never enabled preflight need no source migration for this setting.
These are alternatives in their respective applications. Mobile consumers do not acquire a browser replay API; ordinary consent, persistence, offline behavior, and screen ownership remain with their runtime.
9. Server-only Node and low-level API Client — unchanged commit APIs; scoped preview where supported
Server-only Node's ordinary request event methods remain committing calls, so no migration is required for that flow. The comparison makes that unchanged path explicit.
await scoped.page({ properties: { url: requestUrl } })await scoped.page({ properties: { url: requestUrl } })For custom API integrations, constructor-level preflight still applies to supported single-profile mutations, but it no longer leaks into profile reads or batch ingestion. Use request-scoped preview to make ownership explicit:
The explicit request-scoped form is recommended, not required for valid single-profile constructor-preflight callers. Profile reads and batch ingestion are not supported preview operations. Custom adapters implementing
WebOptimizationRuntimemust add the newhydrateAndTrackCurrentPagemember. The API Schemas facade and preview panel have no new consumer initialization API in this PR.Breaking-change and migration assessment
Comparison baseline: the PR's base branch, which includes published SDK v2.0.1. This assessment concerns the complete branch, including the additive handoff-factory typing fix.
The current branch is behaviorally breaking for supported consumers, and it includes a source-breaking options change. Keeping old field/method names as inert shells does not make those contracts non-breaking. Under normal semantic versioning, the affected published contracts require a major release unless their earlier behavior is explicitly established as unsupported, or the implementation preserves that behavior during a deprecation window. The base release is published, so this cannot be dismissed as replacing only unmerged APIs.
initialPageEvent: 'skip'api.preflight: truecookieOptionsanddeleteWhenProfileCannotPersistare removed from the helper options; automatic server cookie writes stop.persist(response)WebOptimizationRuntimeimplementationshydrateAndTrackCurrentPage.Request-level preflight on valid single-profile API Client mutations remains supported. Preventing constructor preflight from affecting reads and unsupported batch ingestion is a transport correction; it does not remove valid single-profile preview use.
The focused SDK typing fix is additive: optional
hydrationon request handoff factories, literal-preserving return types, and shared metadata type exports. Calls without the new option preserve their old payload shape. The fix leaves Pages Router/Edge helper return types and runtime behavior unchanged.Consumer DX assessment
DX improves materially for hybrid/framework integrations, but not uniformly across the suite. The improvement is clearer ownership and fewer coordination responsibilities, not simply shorter code or lower payload size.
These are source/ownership conclusions, not results of a consumer usability study. Source-line savings, bundle-policy reductions, and the number of examples are not evidence by themselves that DX improved. The strongest supported benefit is the single browser delivery owner and predictable framework request ownership; the main unresolved release concern is preserving or versioning the changed public behavior.
Validation and measured results
Earlier paired-replay implementation validation:
The budget normalization used a fresh
pnpm buildand aggregatepnpm size:report; its amend/push skipped validation and hooks.Before the final amend/push, the narrow handoff-factory typing fix passed workspace typechecking and lint (four existing deprecation warnings), Core/Node/Next.js unit suites, full
pnpm build:pkgs, aggregatepnpm size:reportandpnpm size:check, SDK knowledge, guides, Fern, and formatting checks. Refreshed Node/Web and Angular implementation installs passed their typechecks and implementation lint; the final Next.js typecheck also passed. No E2E was rerun for this typing fix. Mobile/native E2E remains deferred to CI.The latest amend/push ran no validation and bypassed commit/push hooks at the requester's direction.
Bundle budgets
At the requester's direction, every configured release bundle budget was normalized to its gzip measurement at normalization time, rounded to the nearest 100 bytes, plus 100 bytes:
Math.round(gzipBytes / 100) * 100 + 100The normalization covered 81 configured bundles across 9 SDK packages: 14 budgets increased, 40 decreased, and 27 remained unchanged. Headroom at normalization was 52–149 bytes. The report below records that measurement snapshot and the applied thresholds. The subsequent aggregate
pnpm size:checkpassed with the handoff-factory typing fix, without further budget changes. Its Core CJS/ESM measurements were 24,597/23,759 gzip bytes against budgets of 24,700/23,800 bytes. No runtime source, dependencies, bundle entries, aliases, or exports changed as part of the budget normalization.Complete bundle report at budget normalization and applied budgets
@contentful/optimization-api-client
guards.cjsguards.mjsindex.cjsindex.mjs@contentful/optimization-core
index.cjsindex.mjsbridge-support.cjsbridge-support.mjsruntime.cjsruntime.mjsentry-source.cjsentry-source.mjspreview-support.cjspreview-support.mjs@contentful/optimization-node
index.cjsindex.mjs@contentful/optimization-api-schemas
index.cjsindex.mjs@contentful/optimization-web
contentful-optimization-web.umd.jsindex.cjsindex.mjsbridge-support.cjsbridge-support.mjsruntime.cjsruntime.mjshandoff.cjshandoff.mjsanalytics.cjsanalytics.mjscontentful-optimization-web-components.umd.jspresentation.cjspresentation.mjstracking-attributes.cjstracking-attributes.mjsweb-components.cjsweb-components.mjs@contentful/optimization-react-native
index.cjsindex.mjspreview-support.cjspreview-support.mjspreview.cjspreview.mjs@contentful/optimization-react-web
index.cjsindex.mjsrouter/next-app.cjsrouter/next-app.mjsrouter/next-pages.cjsrouter/next-pages.mjsrouter/react-router.cjsrouter/react-router.mjsrouter/tanstack-router.cjsrouter/tanstack-router.mjshandoff.cjshandoff.mjsanalytics.cjsanalytics.mjstracking-attributes.cjstracking-attributes.mjs@contentful/optimization-web-preview-panel
contentful-optimization-web-preview-panel.umd.jsindex.cjsindex.mjs@contentful/optimization-nextjs
app-router.cjsapp-router.mjsapp-router-server.cjsapp-router-server.mjscache-middleware.cjscache-middleware.mjsclient.cjsclient.mjsedge.cjsedge.mjspages-router.cjspages-router.mjspages-router/server.cjspages-router/server.mjsserver.cjsserver.mjsrequest-handler.cjsrequest-handler.mjstracking-attributes.cjstracking-attributes.mjsInternal native bridge outputs
These private build outputs have no configured release budgets and are reported separately.
optimization-ios-bridge.umd.jsoptimization-android-bridge.umd.js