- date
- entry
- 049
- topic
- infrastructure
- rev
- —
Block site data in Chrome and most of the homepage goes blank: reading localStorage throws SecurityError, and nothing after it runs
On October 17 someone reported that the yutuo-tech.com homepage was blank. Locally, with a Chrome profile that blocks site data, I reproduced one path that produces a blank page: the line that reads localStorage throws SecurityError, the whole script stops there, and the code that reveals the sections and fills in the lists, all further down, never runs.
Store a user preference in localStorage and fall back to a default when it isn't there. A common way to write it: localStorage.getItem("lang") || "zh-TW". When storage works but the key has never been stored, getItem returns null, the default kicks in, and "can't read it" looks handled.
But when the browser blocks this site's data, "can't read it" doesn't mean null. It means an exception. The homepage code behind the October 17 blank-page report on yutuo-tech.com had exactly such a path. This note is about how a preference setting made most of a page disappear. In order: which settings make it throw, why the result is a mostly blank page rather than one missing feature, why the usual checks don't catch it, the two parts of the fix, and who else sees the same blank page.
Block site data, and reading the property throws
I ran a local static server and opened a probe page with headless Chrome 155.0.8059.80. I prepared a few --user-data-dir profiles with content settings pre-written to Default/Preferences. The blocking one sets profile.default_content_setting_values.cookies to 2 (block), matching the "don't allow sites to save data" option in settings; Chrome's site-data blocking uses the cookies content setting. The probe's output:
typeof threw: SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document.
access threw: SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document.
in: true
session threw: SecurityError: Failed to read the 'sessionStorage' property from 'Window': Access is denied for this document.
cookieEnabled: true
What throws is reading the window.localStorage property itself, before getItem is ever reached. sessionStorage behaves the same. Leaving the global setting alone and adding a block exception just for http://127.0.0.1:8765 (profile.content_settings.exceptions.cookies) gives the same result. The same probe in a profile with no changed settings succeeds at every step.
The report didn't say which browser or settings the reporter used. What I can confirm is that the homepage code had this path to an abort, and that following it also produces a blank page; it's a possible cause, not a confirmed one.
Why most of the page goes blank
A missing language preference should only mean the page shows the default language. It turned into a mostly blank page because two other things lined up. The first is in CSS: the homepage sections carry a .reveal class styled opacity: 0, and only become opacity: 1 once JavaScript adds is-visible. It's a common way to do scroll-in animations; the homepage HTML has 15 elements with that class, starting with the hero block at the top; 3 of them, in the cases section, are set back to opacity: 1 by other CSS rules and unaffected.
The second is in JavaScript: the IntersectionObserver code that adds is-visible to .reveal sat at line 1332 of main.js (1,539 lines in all), while getLang(), which reads localStorage, was called at line 956. main.js loads as <script type="module">; when an uncaught exception is thrown at a module's top level, the rest of that module's not-yet-run code doesn't run. Also further down is renderAll() on the file's last line: 4 containers on the homepage (the problem list, service cards, cases and process) are empty in the HTML and filled in by it. So after the abort, most sections are transparent and several lists are empty.
On a test page with a single .reveal section, the key JavaScript is three lines:
const getLang = () => localStorage.getItem("lang") || "zh-TW";
let currentLang = getLang();
document.querySelectorAll(".reveal").forEach(n => n.classList.add("is-visible"));
Reading the result with --dump-dom in the site-data-blocked profile, the section is still class="reveal", without is-visible. Taking a 600×200 --screenshot and counting grayscale pixels below 128 with Pillow: 6,988 in the normal profile (that's the heading text), 0 in the blocked one. The text is in the DOM; it's just transparent. These are test-page numbers, not a measurement of the real homepage.
The usual checks don't catch it
Since the exception fires when the property is read, many "check whether localStorage exists first" patterns throw during the check. The probe's first line is typeof window.localStorage, and it throws the same SecurityError; if (window.localStorage) is also a property read, with the same result. 'localStorage' in window doesn't read the value, so it doesn't throw, but it returns true; it only says the property exists, not that storage works.
navigator.cookieEnabled was also true in this test. I've only seen that in headless mode with this profile and haven't compared a normal window or other browsers, so I'm not treating it as a rule; what is certain is that in at least one environment it can't tell you whether storage works. Rather than checking up front, wrap every actual read and write in a try and fall back to a default on failure; one successful read doesn't guarantee a later write will succeed (a full quota, for example).
The fix: try/catch for this line, reordering for the next one
The October 17 fix did two things. I split them apart on the reproduction page to see what each one covers. Moving only the reveal code to the top, without try/catch:
document.querySelectorAll(".reveal").forEach(n => n.classList.add("is-visible"));
const getLang = () => localStorage.getItem("lang") || "zh-TW";
let currentLang = getLang();
document.body.dataset.lang = currentLang;
In the blocked profile the section appears (class="reveal is-visible"), but body has no data-lang: line three threw when it called getLang(), and line four never ran. On the real homepage, what follows the abort point is the language menu, the form handlers and the renderAll() that fills the lists. Reordering alone makes the sections visible, but none of that initialization finishes.
Then wrap the read in try/catch and return the default when it fails:
const getLang = () => {
try { return localStorage.getItem("lang") || "zh-TW"; }
catch { return "zh-TW"; }
};
Now body has data-lang="zh-TW", the section is visible, and the screenshot is back to 6,988 dark pixels. The real main.js changed in three places: the IntersectionObserver block moved to the first thing after the import, and both the read in getLang() and the setItem in the language buttons got a try/catch. When setItem fails, the choice applies to the current page only and this choice isn't saved.
For this bug, try/catch alone is the fix; reordering is extra protection. try/catch handles the line known to throw; putting the reveal code first means that if some other line later throws a synchronous exception nobody anticipated, the IntersectionObserver is already registered, and sections already in the HTML still get is-visible as they scroll into view. The test page adds the class directly while the real homepage waits for the observer's callback, so this means "appears when scrolled to," not "all visible on load." It can't bring back content generated by JavaScript, and it doesn't help if the module fails to load or the reveal code itself fails.
People with JavaScript off see transparent sections too
With localStorage fixed, one more case gives the same transparent sections: a visitor whose browser doesn't run JavaScript at all. In another profile I set profile.default_content_setting_values.javascript to 2, and the screenshot's dark-pixel count was 0 as well. opacity: 0 lives in CSS, and CSS applies whether or not JavaScript runs.
The yutuo-tech.com stylesheet does have one exception: under prefers-reduced-motion: reduce it sets .reveal back to opacity: 1. With the same rule added to the reproduction page and --force-prefers-reduced-motion in the blocked profile, the section is visible (6,988); without that flag it's still 0. So visitors who turned on "reduce motion" see the sections already in the HTML even when the script breaks; the lists filled in by JavaScript are still empty.
If I were rewriting this, I'd let JavaScript switch on the hidden state too: the script's first act adds a class to <html>, and CSS hides .reveal only under that class. If the script doesn't run, the content in the HTML just shows, without the entrance animation; the case where the class gets added but the reveal code then fails still needs handling. Lists generated by JavaScript can only be saved by putting a static copy in the HTML. The October 17 fix didn't take this step; in the fixed version, most sections still wait for JavaScript to appear.
If you remember one thing
When the browser denies this page access to storage (say, site data is blocked), reading the localStorage property throws SecurityError, and typeof and if (window.localStorage) throw while checking. Wrap every preference read and write in try/catch, and put the code that decides whether content is visible ahead of anything that can fail, or better, don't hide content by default at all.
Revision history
- First published