Repository navigation
feat: [SDK-5597] add a feature flag reader with a service worker mirror - #1534
Conversation
…local override
Reads the flag list from GET apps/{appId}/sdk/features/web/{VERSION}
once per page load, in parallel with the config fetch. The last list is
cached in localStorage under os_feature_flags and isFeatureEnabled reads
it on every call, so a fresh fetch applies at once. A failed fetch keeps
the cached list and never blocks init.
A comma-separated localStorage key os_feature_overrides forces flags on
for local testing and logs a warning when set. The service worker does
not read the flag.
Raises the page size-limit budget to the measured 43.04 kB.
Log is silent below the trace level in production builds, so the override warning did not show for developers who set the key.
The worker has no localStorage, so refreshFeatureFlags now writes the active list (server list plus local overrides) to the IndexedDB Options store under featureFlags, and isFeatureEnabledInWorker reads it there.
refreshFeatureFlags wrote localStorage and then IndexedDB. The IndexedDB put resolves without a write when the database circuit breaker is open, and the worker reads only IndexedDB, so a flag turned off on the page could stay on in the worker. The mirror is now written first, and the page list is left as is when that write is dropped.
There was a problem hiding this comment.
Multi-model review
Claude Opus 5.5, GPT 5.6 Sol, and Grok 4.7 reviewed the feature-flag reader and the worker mirror. The design (fetch once per load, never block init, IDB write first so a wedged put leaves both sides on the last shared list) is clear. The agreement is that a few paths still break that page/worker invariant.
Act on (3/3)
isReadwriteWedged()afterdb.putis the page-wide latch, not this write’s result. A successful Options put plus another store wedging, or a late IDB commit after the 1500ms timeout, updates the worker and leaves the page on the old list. The dropped-write test reproduces that split (realput, mocked latch) and never asserts the worker value. Usedb.put’s return (undefinedfromguardvs the key) and assert both stores.- Neither
os_feature_flagsnorOptions.featureFlagsis keyed byappId/VERSION, andinitSaveStatedoes not clear them on app change. A failed refresh after a switch keeps the previous app’s flags, includingsdk_identity_verification.
Consider (3/3)
- Overrides are live on the page (
getFeatureOverrides()every call) but snapshotted into IndexedDB only after a successful fetch. Add or removeos_feature_overrideswhile the request fails and the two sides disagree. void refreshFeatureFlags(...)is eventual-consistency by design and has no callers here. #1531’s one-time init decisions will still run on an empty or stale list. Expose a ready promise or re-run those decisions after refresh.db.putbeforesetFeatureFlagsin onetry: an IDB throw skips the page write; asetItemthrow after a committed put leaves the worker ahead.getFeatureOverrides()is also outside thetry.
Noted: five TypeError retries on a best-effort call; extra Options readwrite racing init; fire-and-forget refresh can leak into later tests via the new default MSW handler.
Dismissed: overrides can only force flags on (intentional for local testing).
Sent by Cursor Automation: PR Reviews
refreshFeatureFlags checked the page-wide circuit breaker after the mirror write, not the result of that write, so a breaker tripped by another store could update the worker and skip the page. The write is now judged by the value db.put resolves to. The page cache now records the app ID. When the cached app differs from the one passed to init, both caches are cleared before the fetch, so a failed refresh after an app change does not keep the old app's flags. The worker mirror is now written on every refresh from the overrides plus the fresh or cached list, so a local override reaches the worker when the fetch fails. The whole refresh runs inside one try so a storage error from the fire-and-forget call does not become an unhandled rejection.
|
Put together an example of the hydration fix plus some size trimming on top of this branch: sherwin/sdk-5597...fadi/pr-1534-size-example Page bundle goes from 43,492 B to 43,292 B (+203 B over main instead of +403 B), so the limit could drop to about 43.3 kB. Tests, What changed:
Feel free to cherry-pick whatever's useful. |
abdulraqeeb33
left a comment
There was a problem hiding this comment.
The per-app cache is right when the fetch succeeds, and a dropped mirror write leaves that page list alone.
Requesting changes on the upgrade path. A failed fetch now mirrors getFeatureFlags(), and a pre-upgrade array reads as [], so that write replaces a good worker list with [].
The app-change clear removes the page list before the worker put is known to have committed. A dropped or thrown clear leaves the worker on the other app's flags for this page. Fine as a follow-up.
Moves refreshFeatureFlags after _initializeCoreModuleAndOSNamespaces so the IndexedDB mirror write can't stall and trip the circuit breaker before init hydrates. Collapses the localStorage helpers into one list parser shared by the page and worker readers, folds the fetch into the refresh, and drops the per-app cache tracking. Page bundle: 43,492 B -> 43,292 B. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided to roll back the per-app cache so this review is now stale


Description
1 Line Summary
Adds a feature flag reader for the page (
isFeatureEnabled) and the service worker (isFeatureEnabledInWorker), fed byGET apps/{appId}/sdk/features/web/{version}once per page load. Split out of #1531 at review request so that PR can stack on it.Details
Page reader (
src/shared/features/featureFlags.ts)refreshFeatureFlags(appId)runs fromOneSignal.initafter_initializeCoreModuleAndOSNamespaces(), withOneSignal.config.appId. Init does not wait for it. Its IndexedDB write comes after the init reads, so a stalled write cannot trip the circuit breaker while the reads run. It callsGET apps/{appId}/sdk/features/web/{VERSION}and storesfeaturesin localStorage underos_feature_flags. A failed fetch or a malformed body keeps the cached list and never blocks init. The cache is not keyed by app ID: after an app ID change, a failed fetch keeps the old app's list until the next successful fetch.isFeatureEnabled(flag)reads storage on every call, so a fresh list applies at once without a cold start.os_feature_overridesforces flags on for local testing. When set, aconsole.warnshows the active overrides (plainconsole.warn, becauseLogis silent below trace in production builds).sdk_identity_verification. feat: [SDK-5120] inline notification icons from the service worker so CDN issues preserve the brand icon #1531 addssdk_web_inline_notification_images.Worker mirror
The worker has no
localStorage, sorefreshFeatureFlagsalso writes the active list (server list plus overrides) to the IndexedDBOptionsstore underfeatureFlags, andisFeatureEnabledInWorker(flag)reads it there. The mirror is written on every refresh from the overrides plus the fresh or cached list, so an override reaches the worker when the fetch fails. The IndexedDB write happens before the localStorage write, anddb.putresolves toundefinedwhen the circuit breaker drops it. In that case the page list is left as is, so the page and the worker see the same list.Source
The first two commits are cherry-picks of
44fbb438andad7056bconfeat/identity-verification. The patches are identical, so that branch drops them on rebase or merge. Therefactor:commit is Fadi's, fromfadi/pr-1534-size-example: it moves the refresh after hydration, keeps the page list as a plain array, shares one list parser between the page and the worker, and removes theLog._warncalls.Bundle size
Page 43.09 kB → 43.29 kB (+200 B). Worker unchanged (12.48 kB). The
build:commits set the page limit to 43.3 kB.Systems Affected
Validation
Tests
Info
vp test run— 63 files, 578 tests pass.vp check— no new errors or warnings.maincurrently has 3 formatting issues and 2 type errors inBell.test.tsfrom the Vite+ 1.3.0 upgrade (chore: upgrade to Vite+ 1.3.0 and TypeScript 7 #1532); they are unchanged here.vp run build:prod— passes with the new limit.src/shared/features/featureFlags.test.ts(20): reader, overrides, override warning, worker mirror, dropped-write case (page and worker both kept), override mirror on a failed fetch, storage error.Checklist
Options.featureFlagsheld the server list plus the override, and the worker read it).Programming Checklist
Interfaces:
Functions:
Typescript:
Other:
elem of arraysyntax. PreferforEachor usemapcontextif possible. Instead, we can pass it to function/constructor so that we don't callOneSignal.contextScreenshots
Info
None; no UI change.
Checklist
Related Tickets