We’ve been running this app for years without React, a JSON API, or a hydration library. It passes server state to the browser by string-concatenating JSON.stringify() into an inline <script> tag. It worked fine for most of that time. The day it bit us, a client-side filter read a global category variable that always reflected the parent org account, so when an admin drilled into a child event of a different category, the filter compared against the wrong thing. The fix was one new injected global and one comment.
The mechanism
The app is a SaaS platform on an older web application stack with server-side JavaScript on IIS. When a page renders, the server builds up a string of JavaScript and dumps it into the page inline. The shape is dead simple:
clientJS += 'globalVars.eventsData = ' + JSON.stringify(this.eventsData || []) + ';';if (this.isSelectEventMode) { clientJS += 'globalVars.isSelectEventMode = 1;';}That string lands in an inline <script> block, the browser executes it, and now the client has a pre-populated globalVars object full of data the server already knew. That is hydration. There is no framework doing it, no __INITIAL_STATE__ convention, no serialization layer. It is +, JSON.stringify, and a semicolon.
The pattern has real virtues. It is dependency-free, and it is auditable in the most literal sense: you can View Source and read the exact JavaScript the server emitted, with the actual values baked in. There is no hydration mismatch warning to chase, because there is no second render to mismatch against. The server says what the client knows, in plain text, once.
It also has a sharp edge we should name before anyone copies the snippet above. JSON.stringify produces valid JSON, not something safe to paste into HTML. The moment a serialized string contains </script>, the HTML parser closes the block early and the rest of your data becomes page markup. In this app the values going through here are account names and usernames, which is to say attacker-controlled text, so this is not hypothetical. The fix is one replace at the injection site that escapes the opening angle bracket:
function inject(name, value) { return name + ' = ' + JSON.stringify(value).replace(/</g, '\\u003c') + ';';}< is still a < by the time the JavaScript parser reads the string, so the data is unchanged. The HTML parser just never sees a tag.
The weakness is structural, and it took a feature change to expose it.
The two ambient globals that were always equal
For most of this app’s life, every page read two account objects:
currentOrg, the logged-in account from the URL.orgCtx, the account whose data you are actually operating on.
Those pointed at the same thing on every request, so the codebase used them interchangeably. Thousands of call sites read one or the other with no thought, because there was no difference to think about.
Then came a new drill-down feature. An org admin could now select a child event from a dropdown, scoped by a ?div=<event> URL param. A server helper mutates the module-level orgCtx global mid-request to point at the selected child, while currentOrg stays on the parent org. The instant that shipped, currentOrg.username !== orgCtx.username became a real possibility, and every line that had treated them as one was now a potential bug.
This is the core tension of hand-rolled hydration. Your injected globals are not one context object. They are several independent variables, and nothing in the pattern tells the client which one is correct for a given job. When there was only ever one true account, that did not matter. The moment there were two, the ambiguity became the entire bug class.
The bug: a filter reading the wrong global
The cross-account link widget lets you connect your other sites on the platform to a larger event. Part of its job is filtering to same-category groups only, so it does not offer to link a site in one category to an event in another. That filter, on the client, compared against a global category var.
Here is the trap. The server set category from currentOrg, the org account. Always. So when an org admin drilled into a child event whose category differed from the parent org, the client filter was still comparing against the parent org’s category. Nothing threw. The filter just produced the wrong set of options, silently, and only in the new drill-into-a-child-event flow that had never existed before. We found it because a user reported that linking sites was offering the wrong groups, and it took a while to understand why the bug only appeared in a specific sequence of clicks.
You cannot grep your way to confidence on a bug like this. The global category is read in plenty of places that do want the org’s category. The fix is not to change what category means globally, because that breaks the silent majority of correct callers. The fix is to give the new flow its own, explicitly-named source of truth.
The fix: one new injected global
In the includes file, inside the script-injection function, the server now injects a second account object carrying the selected event’s fields:
clientJS += 'globalVars.activeCtx = ' + JSON.stringify({ username: orgCtx.username , category: orgCtx.category , accountname: orgCtx.accountname }) + ';';On the client, the filter stopped trusting the ambient category and started preferring the new global, falling back only when it is absent:
var activeCategory = (globalVars.activeCtx && globalVars.activeCtx.category) ? globalVars.activeCtx.category : category;That is the whole code change for the category mismatch. A new bridge variable carrying the event’s identity, and a read site that prefers it. activeCtx sits alongside currentOrg and orgCtx on the client now, a third account-shaped thing, each correct for a different purpose.
The cheapest documentation in the file
Here is the part that matters more than the code. We added a comment in the server source the same day, right where the variable is built:
Use
globalVars.activeCtx.categoryanywhere the correct category is needed client-side instead of the globalcategoryvar which always reflects the logged-in account (currentOrg).
That sentence is the highest-leverage thing changed all day. Think about what the next developer faces without it. They land in a massive client file, they need the current category, and they see a global called category sitting right there in scope. Of course they reach for it. It is named exactly like what they want. The comment is the only thing standing between them and re-introducing the exact bug that was just fixed, because the trap is that the obvious global is the wrong one.
Hand-rolled hydration has no type system pointing you at the correct context object. It has no useContext that throws if you read outside a provider. It has three plausibly-named globals and your own judgment about which one to trust. The comment is what converts “your own judgment” into “the answer is written down right here.” It costs one line. The bug it prevents costs a confused user reporting that linking sites offers the wrong groups, a developer who cannot reproduce it because their test org happens to match the event’s category, and a long hunt to discover the filter trusted the wrong variable.
Takeaway
When a page carries more than one account or context object, ask which one each piece of client code is supposed to trust. A second context appearing can break code that worked for years, with no error and only a wrong result. Write the correct choice down in the source, next to the place the data is injected, because that one line is what stops the same bug coming back.
Related
- A fluent DOM builder for a legacy application: modernize without a rewrite: the incremental markup-generation companion on the same legacy codebase
- The Ambient-Global Bug: When “Current Account” Is a Mutable Global Your Whole App Reads: the deeper pattern of mutable ambient state silently picked up by unrelated callers
- A massive JavaScript file and what it tells you about a profitable legacy app: scale context for why a one-line comment is the highest-leverage change in a large codebase
- Don’t invent a new style, expose the existing one: a product-taste lesson in feature design: adding a new global that mirrors the existing shape rather than inventing a parallel one