Skip to content

Make the session storage cache work across page views - #16

Open
YaroslavVlasenko wants to merge 12 commits into
mainfrom
fix/session-storage-key
Open

Make the session storage cache work across page views#16
YaroslavVlasenko wants to merge 12 commits into
mainfrom
fix/session-storage-key

Conversation

@YaroslavVlasenko

@YaroslavVlasenko YaroslavVlasenko commented Jul 30, 2026

Copy link
Copy Markdown

The session storage key included the per-request session id, so nothing written by one page view could be read by the next: the cache was never hit, orphaned keys piled up for the life of the tab, and the JavaScript properties re-ran with a json refresh on every page view. The key is now just the object name. Session storage is already scoped to one tab, and the id still goes to the server as the session-id parameter. Isolation between integrations on one site stays with the object name.

With the key stable, the paths behind it become reachable for the first time, and are made safe here:

  • clearCache iterates backwards and declares its variables, so a clear removes every matching entry instead of every other one.
  • Strict mode is actually enabled ('use-strict' was an expression, not the directive) and evidenceProperties is declared.
  • hasJSFunctions no longer returns false unconditionally, and agrees with processJsProperties on which snippets will run.
  • An error status is neither treated as data nor cached, on the fetch and the XHR path.
  • A cached entry that cannot be parsed is cleared rather than ignored.
  • The cached response fills gaps in the freshly rendered payload instead of replacing it.
  • Stored parameters are no longer round-tripped, so a refresh reports the current page view's own query evidence.
  • A change handler registered after a synchronous cache hit is caught up.
  • Property flags are written with the response they belong to, so a flag can neither outlive nor predate its payload.
  • The cache is loaded only when no snippet has started, so a snippet in flight always produces its refresh.

Covered by the browser suite in pipeline-dotnet (test/session-storage-cache, PR #357), which is green against this revision.

In non-cookie mode the session storage key was "{{_objName}}_" + sessionId.
The server generates a new session id on every request, so every page load
got its own namespace, nothing written by a previous page load could ever be
found, and the orphaned keys accumulated for the life of the tab.

Measured on a live site: eight keys after one page view, sixteen after two,
twenty-four after three, each under a different GUID. The cached response at
sessionStorage.getItem(sessionKey) was never hit, so the JavaScript
properties were re-evaluated and the pipeline re-called on every page view
instead of once per tab.

Session storage is already scoped to one tab, which is the isolation the
session id appeared to provide, so removing it from the key costs nothing.
The id is still sent to the server as the session-id parameter.

Cookie mode is unchanged.

Also tightens the clearCache prefix match. Without the session id the key is
short enough that a bare startsWith would match an unrelated key on the same
origin, so it now matches the exact key or the key plus a separator.
… stack: with cookies enabled the already-executed flags were still written under a per-request id, so they piled up and scripts without saved values re-ran on every page view. One key shape for both modes removes that and keeps the behaviour uniform. Isolation between pipelines on the same site stays with the object name.

@justadreamer justadreamer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the session id from the key is the right diagnosis. What it also does is turn a set of sessionStorage writes that were previously unreachable into live code: nothing could read them back before, so the caching path has effectively never run. Several things need to hold before a cached response can be trusted on a later page view, and they are worth resolving here rather than afterwards, since this reaches every language SDK through the submodule.

The first two below are the ones that will bite in production.

An HTTP error response is cached and replayed for the rest of the tab's life

Lines 459-492. fetch only rejects on network failure, and xhr.onload fires for 4xx and 5xx as well, so neither error path reaches the clearCache() in the .catch at line 495 or in onerror at line 504. The cloud returns error details as a JSON body, so a transient 429 or 5xx parses cleanly, resolves as though it were data, and is written to session storage under sessionKey. Every later page view in that tab then finds it, replaces the freshly rendered payload with the error body, and reports the flow as complete. Under the previous per-request key the entry was never read back, so the missing status check had no observable effect.

The block comment at line 415 already states the intended behaviour: "The new JSON is then loaded if the request is returned with a success status code".

For the fetch path, at line 459:

        .then(response => {
            if (!response.ok) {
                throw new Error('Request failed with status ' + response.status);
            }
            return response.text();
        })

and for the XHR path, at the top of xhr.onload on line 479:

            // An error status still reaches onload, and error details come back
            // as a JSON body, so they must not be treated as data or cached.
            if (xhr.status < 200 || xhr.status > 299) {
                clearCache();
                reject(new Error('Request failed with status ' + xhr.status));
                return;
            }

A property flag can outlive the page view without the response it belongs to

The _property_ flag is written at line 275 when a snippet starts, but the response is only cached at line 487 once the POST comes back. Now that the flags survive a navigation there is no way back out of the gap between those two writes: if the user leaves the page before the POST completes, the next page view finds the flags set and no cached response, skips every snippet at line 224, and then started === 0 at line 306 marks the flow complete without ever issuing a request. Client-evidence properties stay null for the rest of the tab.

Treating a flag as meaningful only when the response it belongs to is present closes that, at line 221:

                var isCached = sessionStorage &&
                    sessionStorage.getItem(sessionKey) &&
                    sessionStorage.getItem(sessionKey + "_property_" + name);

Re-execution within a single page view is already prevented by the jsPropertiesStarted check at line 211, so this only changes how flags inherited from an earlier page view are treated.

A cached response overrides the payload rendered for the current page view

Lines 298-304. On a cache hit loadJSON assigns straight over json, so a response cached earlier in the tab replaces what the server rendered for this page view. That is backwards: the embedded JSON is newer by construction. If the data file, the consent state, or the set of client hints the browser sends has changed since the cached response was stored, the fresher server result is discarded, and it stays discarded for the lifetime of the tab.

The cache's job here is narrower: supply the properties that need client-side evidence, so that the extra POST can be skipped. Overlaying it onto the fresh payload keeps that benefit without losing anything the server has just sent:

// Cached values fill the gaps in the payload rendered for this page view;
// they never override a value the server has just supplied.
var mergeCached = function(cachedJson) {
    Object.getOwnPropertyNames(cachedJson).forEach(function(group) {
        if (typeof cachedJson[group] !== 'object' || cachedJson[group] === null) {
            return;
        }
        if (json[group] === undefined) {
            json[group] = cachedJson[group];
            return;
        }
        for (var name in cachedJson[group]) {
            if (json[group][name] === null || json[group][name] === undefined) {
                json[group][name] = cachedJson[group][name];
            }
        }
    });
    return json;
}

This needs the same try/catch around the parse that loadJSON has at line 379, and a decision on what should happen when the cached entry turns out to be unparseable.

One consequence worth knowing either way: on a cache hit the whole flow resolves synchronously inside the constructor, before this.onChange has been assigned at line 626, so fireChangeFuncs runs against an empty changeFuncs. A handler registered by page code through onChange is silently never invoked on the second and subsequent page views. complete() is unaffected, because it checks the completed flag before falling back to onChange.

The previous page view's query evidence replaces the current page's

Lines 65-73. parameters is not static configuration: it is rendered from the query.* evidence of the request that produced this page, so it is effectively this page's own query string. Replacing it wholesale means that on any later page view where a request is actually made, the page's own query evidence is discarded in favour of whatever the first page view stored, and then written back over it by saveParameters() at line 428.

Under the previous per-request key this getItem always missed, so the replacement has never taken effect. Merging preserves the carry-over while letting the current page win:

    var loadParameters = function() {
        if (sessionStorage) {
            var parametersString = sessionStorage.getItem(sessionKey + "_parameters");
            if (parametersString) {
                // Merge rather than replace: parameters is rendered from this
                // page view's own query evidence and must win over anything a
                // previous page view stored under the same key.
                var storedParameters = JSON.parse(parametersString);
                for (var name in storedParameters) {
                    if (storedParameters.hasOwnProperty(name) &&
                        parameters.hasOwnProperty(name) === false) {
                        parameters[name] = storedParameters[name];
                    }
                }
            }
        }
        return parameters;
    }

The 51D_* saved values are unaffected either way, since they are added separately from getParametersFromStorage() at line 422.

Strict mode has never been enabled

Line 2 reads 'use-strict', which is not the directive, just an expression statement. It should be 'use strict';. Apply it together with the clearCache suggestion below and not on its own: clearCache currently assigns to an undeclared i and key, and those assignments start throwing the moment strict mode really engages.

Comment thread JavaScriptResource.mustache Outdated
justadreamer and others added 3 commits July 31, 2026 12:15
'use-strict' is an expression statement, not the directive, so strict mode
has never actually been active. Declaring i and key in clearCache is a
prerequisite for the directive taking effect: they were implicit globals,
and the assignments throw once it does.
Co-authored-by: Eugene Dorfman <eugene.dorfman@gmail.com>
@justadreamer

Copy link
Copy Markdown
Contributor

Four points from the earlier review are still open on the current head:

  • no status check before the response is cached, line 490 (fetch path from 462, XHR from 482)
  • the _property_ flag written at 277-279 before the response exists at 490
  • the cached response replacing the freshly rendered payload, 302-306
  • loadParameters replacing rather than merging, 68-76

Worth noting that none of these are introduced here. Every one of those sites is identical on main: lines 482, 216 and 270, 294, and 62 respectively. What changes their consequences is line 8. While sessionKey carried the per-request session id, each of those reads missed on any later page view, so the bad outcomes all needed a page-view crossing that could not happen. Within a single page view the stored data belongs to that same page, which is why none of it showed. With a stable key they all become reachable.

So they are defects this change makes live rather than defects it creates, which is the reason to resolve them alongside it: merged as it stands, they reach every language SDK through the submodule. Suggested fixes for all four are in the earlier review body. If you would rather keep this PR to the key change, raising them as separate issues works just as well, provided they land with it rather than after.

…of server-supplied values, and handle cached JSON parsing errors.
@YaroslavVlasenko

Copy link
Copy Markdown
Author

Four points from the earlier review are still open on the current head:

  • no status check before the response is cached, line 490 (fetch path from 462, XHR from 482)
  • the _property_ flag written at 277-279 before the response exists at 490
  • the cached response replacing the freshly rendered payload, 302-306
  • loadParameters replacing rather than merging, 68-76

Worth noting that none of these are introduced here. Every one of those sites is identical on main: lines 482, 216 and 270, 294, and 62 respectively. What changes their consequences is line 8. While sessionKey carried the per-request session id, each of those reads missed on any later page view, so the bad outcomes all needed a page-view crossing that could not happen. Within a single page view the stored data belongs to that same page, which is why none of it showed. With a stable key they all become reachable.

So they are defects this change makes live rather than defects it creates, which is the reason to resolve them alongside it: merged as it stands, they reach every language SDK through the submodule. Suggested fixes for all four are in the earlier review body. If you would rather keep this PR to the key change, raising them as separate issues works just as well, provided they land with it rather than after.

All four are in, applied as suggested: the status checks on both the fetch and XHR paths, the property flag only counting when its response is present, mergeCached overlaying the cache onto the fresh payload (wired through a small loadJSON split so the cached branch reuses the same continuation, with a graceful clear when the cached text does not parse), and the parameter merge with the current page winning. The existing browser suite in pipeline-dotnet stays green against this revision (57/57). Dedicated red/green tests for each of the four are coming to the pipeline-dotnet PR next, together with the submodule bump.

@YaroslavVlasenko YaroslavVlasenko changed the title Do not put the session id in the session storage key Make the session storage cache work across page views Jul 31, 2026
mergeCached read through a fresh group that was null or a primitive, so
a payload where an aspect is null and the cache has an object for it
threw. In the promises render that surfaced as a rejection; in the
non-promises render it killed the constructor and left the object
undefined. It also index-merged arrays, which extended
javascriptProperties with names the server no longer sends and grafted a
cached errors array onto a payload that had none. Only aspect objects
are merged now, and a value the server has just supplied is never merged
into.

An unparseable cached entry was cleared after the property flags had
already been read, so the page view completed having neither run the
snippets nor made a request, and an entry that parsed to null was never
cleared at all. The payload is now read and validated once, before the
flags, so a bad entry sends this page view down the request path.

On a cache hit the flow resolved inside the constructor, before any
onChange handler could be registered, so those handlers silently never
fired on the second and subsequent page views. The cached continuation
is deferred by a tick, which is what the request path gets for free.

device.javascripthardwareprofile wrote its flag before the empty-profile
back-out and the back-out did not remove it, so once any response was
cached the property was never attempted again for the life of the tab.
The flag is now removed with the back-out. The splice it replaces was
indexing jsPropertiesStarted with the position in jsProperties.

hasJSFunctions self-initialised its counter and compared it against the
array rather than its length, so it always returned false and the
re-process branch was dead. It now walks the list under the same
conditions processJsProperties runs a snippet under - not already
started, not delayed, non-empty body - and properties skipped as cached
are recorded as started, so the two agree and the branch cannot spin.

loadParsedJSON moved out of the update section: the cached branch calls
it unconditionally, and a stable key means a payload cached by an
update-enabled render can be present when an update-disabled one with
the same object name is constructed.
The one-tick deferral was not enough: page code registers its handlers
from the load event, which fires long after a setTimeout scheduled during
the include. Handing the current payload to a handler registered once the
flow has already finished is what complete already does, and it works
whenever the registration happens.
The object is rendered from one page view's query evidence, so it is
already exactly what that page view's refresh should report. Storing it
and reading it back on the next page view can only add query parameters
that belong to a different page: a campaign or tracking value present on
the first page is replayed on every refresh for the rest of the tab
session, and the merge added before this only decided which page won a
name they both had.

The values the snippets produce are the one thing that has to carry over,
and they never came from here - getParametersFromStorage reads them from
the 51D_ cookies or the _data_ keys, and processRequest merges them in on
its own. So the stored copy was duplicating a mechanism that already
works and putting stale evidence on top.

saveParameters' sourceParams branch went with it: nothing has ever called
it with an argument.
Nothing in this repository can be executed on its own: the templates are
consumed as a submodule and rendered into an inline script, so their
behaviour is only visible in an SDK that embeds one and drives it in a
browser. Until now a change here reached every SDK with no check at all
and was found, if at all, by whichever consumer bumped the submodule
first.

The job checks out pipeline-dotnet, copies this revision over the one it
pins, and runs the JavaScript builder tests. It reports when the copy
changed nothing, because the templates are embedded resources and an
unchanged working tree would produce a pass that says nothing about the
pull request.

pipeline-dotnet is tested at main, which is what the change has to land
against. Use the workflow_dispatch input to point it at a branch when the
tests for a change are still in review themselves.
It lands ahead of this one, so carrying it here as well would mean this
pull request deleting it again on merge. Nothing else here depends on it.
Two states remained in which a page view could complete from the session
storage cache while the evidence it holds had not reached the server.

The cached === toProcess disjunct was reachable with a snippet in flight:
toProcess only counts a property that has a body in the freshly rendered
payload, cached counts one whose flag is set regardless, so a property the
server has already resolved raises cached without raising toProcess. With
the counts equal the cached payload was loaded, the promise resolved and
the request suppressed, discarding the evidence the running snippet had
just produced. started === 0 alone is the condition that was meant: a
snippet that runs raises started, so every legitimate all-cached case
already satisfies it. cached and toProcess have no other reader.

A property flag was written when its snippet started but the response only
when the request returned, so leaving the page in between left the flag
behind. Any older cached payload then validated it, and from the next page
view on the property counted as done, started stayed at 0 and no request
was ever made again for the life of the tab. The flags are now written with
the response they belong to, from a pending list, so neither can be present
without the other.
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.

3 participants