Skip to content

Improve RealContentScopeScripts.getScript() - #9335

Merged
anikiki merged 4 commits into
developfrom
feature/ana/improve_realcontentscopescripts_getscript
Jul 30, 2026
Merged

Improve RealContentScopeScripts.getScript()#9335
anikiki merged 4 commits into
developfrom
feature/ana/improve_realcontentscopescripts_getscript

Conversation

@anikiki

@anikiki anikiki commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Task/Issue URL: https://app.asana.com/1/137249556945/project/1200581511062568/task/1216821003646786?focus=true
Tech Design URL (if applicable):
API Proposals URL(s) (if applicable):

Description

Reduces the per-navigation cost of getScript() without changing its output, behind the optimizeContentScopeInjection kill-switch (DefaultFeatureValue.INTERNAL). Every input is still read on every call, so nothing can go stale — the savings are all downstream, in skipping work whose inputs didn't change and doing the rest more cheaply.

Optimized path:

  • Plugin config() and preferences() accumulate into StringBuilders instead of reassigning a String per plugin, which copied the whole accumulator each time — quadratic in the assembled config size, across ~22 plugins, on the main thread.
  • The contentScope JSON is rebuilt only when the assembled config or the exception list actually changed, reusing the already-serialized exceptions instead of re-serializing and re-comparing per call.
  • runBlocking is skipped entirely when no contentScope experiment is active — getActiveExperiments() returns only enabled toggles, so the result is then a constant.
  • Toggle.getCohort() is called once per toggle instead of twice; it can enrol to self-heal a stale cohort, so the second call wasn't a free read.
  • The feature flag is read once per call rather than at each use site, so a flag flip can't mix the two paths mid-call.
  • Messaging parameters and the version/platform constants are assembled once, and the messaging parameters are embedded when building the preferences JSON instead of being substituted from a placeholder during assembly.

An earlier revision cached the assembled plugin config and invalidated it on privacy-config persistence.
That was removed: a feature repo that hasn't finished its async load reports its default JSON rather than an
empty string, so the startup stabilisation latch could settle on defaults and serve them for the rest of the
process.

Deliberately unchanged: allow list, unprotected temporary, plugin config() and preferences(), cohorts and
desktop mode are evaluated on every call, so config-driven and user-facing settings (GPC, forced zoom,
allowlisting) apply immediately.

Rollback: with the flag off, every call runs optimized = false end to end through the original helpers —
throwaway Moshi instances, the original quadratic parameter assembly, the original cohort filter/map, and the
chained-replace assembly. The legacy path is byte-and-behaviour identical to before and is deleted with the
flag once rollout completes. whenOptimizeInjectionEnabledThenOutputIsByteIdenticalToFallbackPath compares
the two paths in full, and two further tests pin the freeze via the per-flag getCohort() and
appBuildConfig read counts.

Steps to test this PR

  • Tests are passing.

flag ON (default on internal)

  • Fresh install, browse several pages in a row, confirm protections stay applied.
  • Override the privacy config from dev settings, confirm a config-driven change takes effect.
  • Toggle GPC in Settings, reload, confirm globalPrivacyControlValue takes effect (sec-gpc: 1 is not
    sent when GPC is off).
  • Toggle Accessibility → force zoom, reload, confirm behaviour changes.
  • Tap the shield to allowlist a site, confirm protections drop, then re-protect it.
  • Switch to desktop site and confirm the change is reflected.

Rollback is clean

  • Turn the flag OFF, restart, repeat the above. Behaviour must be identical to develop.
  • Flip it back ON without restarting, load a page, confirm protections are still correct.

NO UI changes


Note

Medium Risk
Changes how often privacy-config-driven script JSON is rebuilt and when plugin config updates apply (persist vs startup latch), which could affect protection behavior if invalidation or settling logic is wrong; legacy path preserved behind a kill-switch.

Overview
Adds an optimized getScript() path behind optimizeContentScopeInjection, while keeping the prior logic as getLegacyScript() for rollback.

Caching & invalidation: Assembled plugin config() is cached and only rebuilt when onPrivacyConfigPersisted() sets pluginConfigNeedsRebuild, or during startup until the config stabilizes (empty configs never latch). RealContentScopeScripts now implements PrivacyConfigCallbackPlugin; onPrivacyConfigDownloaded() is intentionally a no-op.

Per-call work: Allow list, plugin preferences(), desktop mode, and cohorts still update every navigation. Content-scope JSON is rebuilt only when cached plugin config or unprotected-temporary exceptions change. Messaging secrets, version/platform strings, reusable Moshi adapters, and single-pass template assembly are reused on the optimized path; cohort reads go from two getCohort() calls to one per toggle.

Flag behavior: Mid-session flag flips route entirely through one path; legacy sets pluginConfigNeedsRebuild so switching back to optimized does not resurrect stale plugin config.

Reviewed by Cursor Bugbot for commit 1d54e28. Bugbot is set up for automated code reviews on this repo. Configure here.

anikiki and others added 2 commits July 28, 2026 19:00
Builds on the plugin config cache by removing work that still ran on every
navigation, and freezes the flag-off path so disabling
optimizeContentScopeInjection is a true rollback.

Optimized path:
- Rebuild the content scope JSON only when the plugin config or the
  unprotected temporary exceptions actually changed, reusing the already
  serialized exceptions instead of re-serializing and re-comparing the whole
  string on every call.
- Read the feature flag once per call instead of at each use site, so a flag
  flip can no longer mix the two paths within a single call.
- Cache the messaging parameters and the version/platform constants, and embed
  the messaging parameters when building the preferences JSON so the template
  assembly no longer substitutes a placeholder.
- Call Toggle.getCohort() once per toggle rather than twice; it can enrol to
  self-heal a stale cohort, so the second call was not a free read.
- Never latch the startup stabilisation check on an empty plugin config, which
  could otherwise freeze an empty feature config for the session if two early
  navigations both ran before the feature repos finished loading.
- Invalidate the cached plugin config when the legacy path runs, so a flag flip
  mid-session cannot resurrect config that was dropped while it was disabled.

Legacy path is unchanged end to end: every call runs with optimized = false
through the original helpers, throwaway Moshi instances, cohort filter/map and
chained-replace assembly. It is deleted together with the flag once the
optimized path is rolled out.

Adds tests for the flag flip, the empty-config latch, the exceptions-only
rebuild after settling, the download-does-not-invalidate contract, and the
per-flag call counts that pin the legacy freeze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

anikiki commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@anikiki anikiki changed the title Enhance getScript in RealContentScopeScripts with optimized plugin config handling and caching. Improve RealContentScopeScripts.getScript() Jul 29, 2026
@anikiki
anikiki marked this pull request as ready for review July 29, 2026 13:20
anikiki and others added 2 commits July 30, 2026 12:20
The cache could latch onto default protections for the rest of the process.
Feature repos load their persisted config into memory asynchronously with no
completion signal, so the cache used a stabilisation latch: keep sweeping the
plugins until the assembled config stops changing, then trust the dirty flag.

The latch's empty-config guard does not reach the case it was written for. A
plugin whose repo has not loaded does not report an empty config, it reports the
entity's default JSON wrapped in the feature key ("elementHiding":{}), so the
assembled string is non-empty and stable during the whole cold-start window.
Two navigations inside that window settle the latch on defaults, and nothing
reloads it: onPrivacyConfigPersisted() fires once per process from
LocalPrivacyConfigObserver, racing the repo loads rather than following them, so
recovery needs a remote config download. Offline, never. The symptom is element
hiding, fingerprint protection and cookie protection silently absent, with no
crash and no pixel.

Assemble the config on every call again, via the single getPluginParameters()
pass that also builds the preferences, and keep cachedPluginConfig purely as a
comparison baseline. Every input is now read per call, so nothing can go stale,
and the savings that motivated the cache are unaffected: an unchanged config
still skips re-serializing the unprotected temporary exceptions, rebuilding the
content scope JSON and reassembling the template. The preferences sweep already
ran unconditionally, so this replaces two separate sweeps with one.

Removes the PrivacyConfigCallbackPlugin binding, which existed only to
invalidate the cache, and the flag-flip write that kept the two paths in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path

Two changes, both confined to the optimized path so that disabling
optimizeContentScopeInjection stays a true rollback.

Plugin parameter assembly no longer reassigns Strings. The original builds the
config and preferences by `+=` per plugin, which copies the whole accumulator
each time: quadratic in the size of the assembled config, across ~22 plugins,
on the main thread on every navigation. getOptimizedPluginParameters
accumulates into StringBuilders instead. The original is renamed
getLegacyPluginParameters and kept byte-for-byte, matching the existing
getLegacyScript / getOptimizedScript pairing, with each twin naming the other so
the duplication is visible at both declarations. Duplicated deliberately rather
than shared, to leave the flag-off path untouched.

Experiment cohorts no longer enter runBlocking when there is nothing to read.
getActiveExperiments() returns only enabled toggles, so the list is empty
whenever no contentScope experiment is running, and the result is then the
constant "currentCohorts":[]. That case now returns before runBlocking, which
also removes the main-thread runBlocking from the common path. EMPTY_COHORTS is
built from EMPTY_JSON_LIST so it cannot drift from what Moshi emits for an
empty list.

Both are output-preserving, and the existing
whenOptimizeInjectionEnabledThenOutputIsByteIdenticalToFallbackPath test proves
it: it normalises the random messaging secrets and compares the two paths in
full, with an empty experiment list. Its fixture never stubbed preferences(),
so the preferences accumulator was compared while empty; it now has two
preferences contributors as well as two config ones. Verified by mutation:
changing the optimized separator to ';' and changing EMPTY_COHORTS both fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@anikiki
anikiki merged commit a9c2e87 into develop Jul 30, 2026
45 of 50 checks passed
@anikiki
anikiki deleted the feature/ana/improve_realcontentscopescripts_getscript branch July 30, 2026 16:24
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