# 💥 Gotchas

Real-world SharePoint traps, documented as **symptom → cause → fix** — so a problem that cost us hours costs you minutes.

Every article carries frontmatter with `tags` and `applies-to`, so repo search gets you there fast (try `path:gotchas threshold`).

## Index

### rest-api/

| Gotcha | TL;DR |
|---|---|
| [Unbounded `Promise.all` fan-out invites 429](rest-api/unbounded-promise-all-fanout-throttling.md) | Fan-out is safe when YOUR code decides the count, dangerous when the customer's data does; bounded batch and worker-pool patterns |
| [`POST /views` takes an `SP.View` body](rest-api/creating-a-view-posts-sp-view-not-viewcreationinformation.md) | `ViewTypeKind` from the creation-information docs 400s, and an `@odata.type` annotation passes only with `OData-Version: 4.0`; measured recipe for view + scope + column order |
| [Get lists by URL, not by title](rest-api/get-list-by-url-not-by-title.md) | `getbytitle()` breaks the moment someone renames a list; resolve by URL (bonus: `GetList` normalises folder paths to their list) |
| [REST `DELETE` on an item is permanent](rest-api/delete-is-permanent-recycle-is-the-bin.md) | The usual delete call skips BOTH recycle bins; `/items(id)/recycle()` is what a “restorable for 93 days” promise needs |
| [Actions go before the alias query string](rest-api/actions-must-precede-the-alias-query-string.md) | Appending `/breakroleinheritance(...)` to a `GetList(@u)?@u=...` URL buries the action inside the parameter — splice it in before the `?` |
| [FieldValuesAsText re-encodes underscores in JSON keys](rest-api/fieldvaluesastext-reencodes-underscores.md) | `A_x0020_B` comes back as `A_x005f_x0020_x005f_B`; decode `_x005f_` before matching |
| [`GetObjectSharingInformation` traps](rest-api/getobjectsharinginformation-traps.md) | GET is 405 every time (stop retrying it: 2.7× slower), `CreatedBy` is always null, `$expand` is dead weight |
| [Search REST needs `odata-version: 3.0`](rest-api/search-api-needs-odata-version-3.md) | The header behind mysterious search 500s |
| [DateTime: write full ISO, derive days locally](rest-api/datetime-write-full-iso-read-local-day.md) | No-timezone writes 400; UTC reads shift the day; read a day in the zone it was written in — including noon-UTC dates at UTC+12 to +14; a bare date in $filter is UTC midnight, so `le` drops the last day of a range |
| [Drop `__metadata` from write bodies](rest-api/metadata-body-requires-verbose.md) | Old-tutorial payloads 400 as plain JSON, and switching to `odata=verbose` 400s again under `SPHttpClient`, which sends `odata-version: 4.0`; write plain JSON without the type hint, or blank `odata-version` where the verbose form is really needed |
| [File upload via `/Files/add`: nometadata works, an empty `OData-Version` breaks SPFx](rest-api/file-upload-406-needs-verbose.md) | `nometadata` + octet-stream under the default OData v4; an empty `OData-Version` without `Content-Type` throws inside `SPHttpClient`; plus the empty-filename mobile camera trap |
| [`AddValidateUpdateItemUsingPath` answers 200 when it creates nothing](rest-api/validate-endpoint-answers-200-when-nothing-was-written.md) | Field errors sit inside a 200 and no item is created; a write to a missing column is a 400 on every path, never a silent drop |
| [Apostrophes in OData literals](rest-api/odata-string-literals-and-apostrophes.md) | `encodeURIComponent` leaves `'` alone; double it |
| [An unencoded `#` in a `$filter` value cuts the URL](rest-api/unencoded-hash-in-a-filter-value-cuts-the-url.md) | The browser drops everything after `#` as a fragment, SharePoint gets an unterminated literal and answers 400; guest UPNs (`#EXT#`) always hit it; `&` cuts the expression and a raw `+` arrives as a space, so a valid literal silently matches nothing (measured) — `encodeURIComponent(odataString(x))` |
| [A `#` or `%` in a file or folder name](rest-api/hash-and-percent-in-file-names-need-the-resourcepath-api.md) | The classic `…ServerRelativeUrl` lookups, `Files/add(url=)`, `folders/add`, `moveto` and `copyto` cannot reach it even encoded: 404/400, `Exists: false` for a folder that exists, and writes that answer 200 but save a literal `%25`; ResourcePath API with the path doubled and URL-encoded, plus its different answers on an existing target (measured) |
| [`ensureuser` returns the login name](rest-api/ensureuser-returns-the-login-name.md) | The response already carries `LoginName`; a follow-up `siteusers` filter on `Email` misses accounts whose UPN differs |
| [Choice fields accept any value](rest-api/choice-fields-accept-any-value.md) | REST skips choice validation entirely; enforce vocabulary yourself |
| [A decimal comma erases a Number column](rest-api/decimal-comma-erases-a-number-column.md) | `Number('7,25')` is NaN, `JSON.stringify` sends `null`, SharePoint answers 204 and empties the column (measured: 7.25 → null on edit); parse text input comma-aware and reject NaN |
| [Lookup fields need `$expand`](rest-api/lookup-fields-need-expand.md) | Read via `$expand` + projections, write via `<Name>Id`; ~12-lookup query limit |
| [File size needs `$expand=File`](rest-api/file-size-needs-expand-file.md) | `File_x0020_Size` 400s in `$select`; use `File/Length` |
| [Uploaded `.html` gains mso document properties; Office files are rewritten on upload](rest-api/html-upload-gains-mso-document-properties.md) | Setting column values rewrites the file; hash read-back fails for markup, holds for PDF; Office packages change on upload itself… |
| [“Invalid text value” is a MaxLength 500](rest-api/invalid-text-value-is-a-maxlength-500.md) | Generic 500 naming no field; a `Text` column capped at 255 |
| [Create a modern page via REST (3-step)](rest-api/create-modern-page-via-rest-sitepages.md) | `CanvasContent1` won't stick on create; create → SavePageAsDraft → Publish, canvas is JSON; plain JSON is enough, `Description` needs its own write and a re-publish; the file name is random — rename with moveto |
| [`$filter` on multi-value person fields 400s](rest-api/filter-on-multivalue-person-field-400.md) | Fall back to client filtering, but only on HTTP 400 |
| [`GetStorageEntity` returns 200 for a missing key](rest-api/getstorageentity-returns-200-for-missing-key.md) | Unset tenant property answers `200 {"odata.null":true}`; "unset" and "failed" are indistinguishable by status code, and writing needs Tenant Admin — same trap in `…?$select=Exists`, which returns `200 {"Exists":false}` |
| [Silent read failure drives delete-all](rest-api/silent-read-failure-drives-delete-all.md) | A quiet `if (!ok) break` turns "sync what changed" into "delete everything"; fail-safe for a view means show what you can, for a delete it means do nothing |
| [Batch file move: group by file, not by record](rest-api/batch-file-move-group-by-file-not-record.md) | When records share a file URL, iterating records moves it once and 404s on the rest; group by file, write the new URL to every record, and adopt a file already at the target on a rerun |
| ["File is missing" vs. "I cannot read it"](rest-api/missing-file-404-vs-cannot-read.md) | A `T \| null` helper folds 404, 403, 500 and network failure into one null, and the friendly empty state then lies to the one user who has overdue work; return `{ok, status, data}` and let only 404 (on every candidate path) mean empty |
| [`/_api/HubSites` returns an empty list, not 403](rest-api/hubsites-returns-empty-list-not-403.md) | The hub list is security-trimmed, so an account that cannot read the hub sites gets `value: []` with HTTP 200; "this site has no hub" and "no inherited owner" must never be derived from it — read `IsHubSite`/`HubSiteId` from `_api/site` and treat "belongs to a hub that is not in the list" as unknown |
| [A deleted site vs. a site you cannot see](rest-api/deleted-site-vs-site-you-cannot-see.md) | `_api/web` on a non-existent site collection answers 404 (measured, no redirect), so `if (!r.ok) return unknown` makes a dead row in your own site inventory look like a site you merely lack permission on; keep `r.status`, treat 404 as a defect in YOUR list (with a fix), and stay silent about everything else |
| [App page properties are not in CanvasContent1](rest-api/app-page-webpart-properties-not-in-canvascontent.md) | Single-part app pages store web part config elsewhere; do not diagnose "not set" from page fields — but the web part id IS there, so use it to find the hosting page (not ClientSideApplicationId) |
| [Field written but missing from `$select`](rest-api/field-written-but-missing-from-select.md) | `$select` is an allowlist; a `snapshot ?? live` fallback silently erases the feature the snapshot exists for |
| [Silent fallbacks poison destructive writes](rest-api/silent-fallbacks-poison-destructive-writes.md) | `catch → []` + delete-then-insert = data loss; strict and safe reads (+ a settings read that hides its failure lets a full-object save overwrite the config with defaults) |
| [Check-then-insert races produce duplicate rows](rest-api/check-then-insert-races-duplicate-rows.md) | GET-check then POST-insert without a unique column races into double inserts; dedup on read by version, never delete "lowest Id" |
| [SharePoint REST has no increment](rest-api/a-counter-written-from-a-stale-read-loses-votes.md) | A MERGE writes a value, so `count + 1` from what the page loaded, sent with `IF-MATCH: *`, overwrites every vote cast since that page load; count distinct `AuthorId`s of per-user rows, or read fresh and write with the item's ETag |
| [The browser cache answers your read-after-write check — with a 200](rest-api/browser-cache-answers-your-read-after-write-check.md) | The same URL read on both sides of a write is served from the browser cache, so `res.ok` guards pass on stale state; a cached existence check makes `POST /fields` create a duplicate column and report success, and an append-only number allocator loses every round (check for double-encoded filters too); bust only reads on the write path |
| [A list's ETag tracks its schema, so `ItemCount` comes back stale via 304](rest-api/list-metadata-etag-tracks-schema-not-content.md) | Adding items never moves the list entity's ETag, so revalidation answers 304 and the browser serves the body from when the list was empty; affects entity reads (`GetList`, `lists(guid)`), not collections or `/items` |
| [Provisioning skips schema changes to existing fields](rest-api/provisioning-skips-schema-changes-to-existing-fields.md) | Create-if-missing never updates an existing field; a new Choice value in the manifest no-ops on deployed sites; reconcile with a post-hook `SP.FieldChoice` MERGE (plain JSON with `@odata.type` under `SPHttpClient`) that only ever widens the set — `Choices` is replace, so a failed read of the current values turns the union into the manifest and deletes the rest, while items keep the values the definition no longer knows |
| [`X-RequestDigest` expires mid-session](rest-api/request-digest-expires-mid-session.md) | Writes 403 "security validation is invalid" on a long-open page; raw `fetch` needs a fresh digest from `/_api/contextinfo`, but in SPFx a hand-set header switches `SPHttpClient`'s own digest and 403-recovery OFF |
| [PDF conversion endpoint: plain fetch only](rest-api/pdf-conversion-endpoint-plain-fetch-only.md) | `_api/v2.0/drives/…/content?format=pdf` converts Office files server-side with no Graph consent, but auth-decorating clients break its 302 to `*.svc.ms`; resolve the drive explicitly and guard on Content-Type |
| [Don't cache a throttled permission probe](rest-api/dont-cache-a-throttled-permission-probe.md) | A 429/403 on `currentuser/groups` resolves to the lowest role; cache it and the user is stuck read-only for the TTL; only persist a confirmed (200) result |
| [There is no `sitegroups/removebyname`](rest-api/delete-group-by-name-no-removebyname.md) | `GroupCollection` has `GetByName` but only `RemoveById`; resolve the Id first, then delete; verify with `getbyname` → 404 |
| [Telling a list's own columns from inherited ones](rest-api/which-columns-are-the-librarys-own.md) | `Hidden` is not enough; `FromBaseType` + `CanBeDeleted` are what separate the list's own columns |
| [A page-size cap reported as a finding](rest-api/page-size-cap-reported-as-a-result.md) | A round number (100 / 500 / 999 / 5000) is the tell; the limit of the request presented as a measurement of the tenant, then cached |
| [File versions come back oldest-first](rest-api/file-versions-are-oldest-first.md) | `/Versions` is ascending, so `$top` drops the NEWEST ones; sorting the result descending afterwards hides it; `$orderby=ID desc` IS honoured together with `$top` (measured), and the list never contains the current version |
| [`RecycleByID` sends a file version to the recycle bin — `DeleteByID` does not](rest-api/recycle-file-versions-instead-of-deleting-them.md) | The two removal methods on `/Versions` differ: permanent vs. site recycle bin (`ItemType 2`, restorable); measured, with `vid = major*512 + minor` |
| [Retrying a throttled call is safe for GET only](rest-api/retry-on-throttling-only-for-get.md) | 429 means "not now", not "nothing happened"; no idempotency key exists, so a retried write duplicates the row (+ `RateLimit-*` headers are not guaranteed for delegated calls) |
| [`fields/getbyinternalnameortitle` 400s for a missing field](rest-api/getbyinternalnameortitle-400-not-404.md) | It throws `ArgumentException` (HTTP 400), not 404; an existence-check that hard-fails on non-404 breaks the "field missing → create" path, and one that creates on any non-200 duplicates the column when throttled |
| [A column called `MyField0` appears](rest-api/field-created-twice-gets-a-numeric-suffix.md) | `POST /fields` with a taken name succeeds with a numeric suffix; 429 read as "missing", a cached check or two concurrent web parts each make a twin — three-state check, single run per site, name check after the create |
| [`getfolderbyserverrelativeurl` 200s for a missing folder](rest-api/getfolderbyserverrelativeurl-200-for-a-missing-folder.md) | It answers HTTP 200 with `{"Exists": false}` (and `ListItemAllFields` 200 with `{"odata.null": true}`), so a check on `response.ok` skips creating the folder and the next call 404s inside `breakroleinheritance`; measure the value, keep three states, and use the path-based lookup — the classic one says `Exists: false` even for an existing folder with `#` or `%` in its name |
| [A Hyperlink column takes an object, not a string](rest-api/hyperlink-field-takes-an-object-not-a-string.md) | It reads back as `{ Description, Url }` (`[object Object]`, React #31) and a plain string fails the whole save with 400; one helper each way, 255-character address limit, scheme allowlist |
| [`webUrl + serverRelativeUrl` doubles the site path](rest-api/weburl-plus-server-relative-url-doubles-the-site-path.md) | Survives for months as a display value or grouping key and breaks the first time it becomes a filter; build object URLs from the origin in one helper |
| [A folder's `Files` collection rejects a two-level `$expand`](rest-api/folder-files-reject-a-two-level-expand.md) | `ListItemAllFields/Editor` 400s there; take `ModifiedBy`/`Author` from the file, and list one level through the folder API rather than a `FileDirRef` filter |
| [`moveto` succeeds and your code reports a failure](rest-api/action-endpoints-return-an-empty-body.md) | Action endpoints (`moveto`, `removebyid`) return an empty body, so `response.json()` throws after the operation happened and invites a retry; decide by status, parse only what you read |
| [`Overwrite=true` in a shared folder steals another record's file](rest-api/overwrite-true-in-a-shared-folder-steals-another-records-file.md) | Uploading with `Files/AddUsingPath(…,Overwrite=true)` under a user-supplied file name into a folder that holds documents of many records (cases, orders, tickets) lets a second file with the same generic name (such as `image.jpg`) replace the first record's file — SharePoint keeps the same list item and adds a new version — and the metadata update that follows then re-links that item to the second record. Put the record key into the file name (or give every record its own folder), and add it when moving older files |
| [Event dedup by timestamp watermark loses events](rest-api/event-dedup-by-timestamp-watermark-loses-events.md) | When you pull activity events (opens, clicks, audit entries) in batches with an overlapping cursor, "skip anything not newer than the last counted event" throws away a second event with the same second-precision timestamp and any late-arriving event with an older timestamp — exactly the ones the overlap exists to catch; dedupe inside the overlap by event identity (action + timestamp + occurrence count) and let the watermark decide only what lies outside it |
| [Image web part via REST (canvas JSON)](rest-api/image-webpart-in-canvas-via-rest.md) | Out-of-the-box Image web part (`d1d91016-…`) in `CanvasContent1`: file ids in `properties` + `customMetadata`, the URL in `serverProcessedContent.imageSources`, the click target in `links.linkUrl`; works in a full-width section and in columns |
| [Missing field lookup = 400, not 404](rest-api/missing-field-lookup-returns-400-not-404.md) | `fields/GetByInternalNameOrTitle` on a missing field answers 400; test existence with a `$filter` instead |
| [Attachment add changes the ETag](rest-api/adding-an-attachment-changes-the-item-etag.md) | `AttachmentFiles/add` changes the item ETag; re-read before an `IF-MATCH` update |
| [ETag key is `@odata.etag`](rest-api/etag-key-is-odata-etag-with-at-sign.md) | SPHttpClient speaks OData v4: the item ETag comes as `@odata.etag`; reading `odata.etag` yields undefined and your IF-MATCH protection quietly disappears |
| [Copy and Move keeps versions, timestamps and authors](rest-api/copy-and-move-keeps-versions-timestamps-and-authors.md) | The server-side Copy and Move job preserves all versions, Created/Modified and Created By/Modified By, leaves the source in place, and is callable from the browser under delegated auth; omitting AllowSchemaMismatch is an immediate 500, a lower version limit on the destination truncates history after the transfer, and — measured — a plain CopyTo loses the version history but keeps the author |
| [Copy job: library root, one-shot logs, harmless warnings](rest-api/copy-job-library-root-progress-logs-and-warnings.md) | Passing a document library's root folder URL to CreateCopyJobs fails with HTTP 500 "Null value for source item" while a subfolder URL copies the folder with its content; several file names containing the hash sign break a multi-URI job with HTTP 400 unless it is sent as %23; GetCopyJobProgress returns only the log entries queued since the previous call, a job emits several JobStart/JobEnd pairs so JobEnd alone is not completion (JobState 0 is), a successful copy can carry a JobWarning about label based policy, ListItemAllFields refuses $expand=Author, and item-level unique permissions are not copied (IncludeItemPermissions fails with HTTP 500 on a copy) but can be re-applied afterwards without touching Modified, every job must take URIs from one parent folder into a destination that already exists, and NameConflictBehavior 0 silently skips an existing file when the same job also carries a new one, so the conflict count must come from the destination, not from the log |
| [JoinHubSite moves sites between hubs](rest-api/joinhubsite-moves-site-between-hubs.md) | Calling `JoinHubSite` on a site that sits in hub A returns 200 and the site is in hub B at once — no unjoin step, no warning, so any "apply hub" plan must read the current hub first |
| [REST request body is capped at 2 MB](rest-api/rest-request-body-2mb-limit.md) | SharePoint Online rejects any REST request whose body exceeds 2,097,152 bytes (after JSON escaping, in UTF-8). A multi-line text field in a list holds far more than that – the cap is the request, not the field – so measure body bytes and split large JSON into parts. |
| [Reindex a library from code under NoScript](rest-api/reindex-library-from-code-works-under-noscript.md) | Bumping `vti_searchversion` on the library's RootFolder property bag via CSOM succeeds on sites with custom scripts disabled (unlike the web property bag); find the library from any item URL with `GetWebUrlFromPageUrl` + `web/GetList` |
| [Missing attachment is 400, not 404](rest-api/missing-attachment-returns-400-not-404.md) | `AttachmentFiles('name')` on a missing attachment returns 400 ArgumentOutOfRangeException; check existence from the attachment list |
| [POST /fields rejects a subtype property like RichText unless the body names the subtype](rest-api/field-subtype-property-without-type-is-400.md) | { FieldTypeKind: 3, RichText: false } without @odata.type is read as SP.Field and fails with 400 – plain text columns in the same loop succeed, so the list ends up half-built; use CreateFieldAsXml or annotate the type |
| [Copy job ends before the target is visible](rest-api/copy-job-end-before-target-visible.md) | After CreateCopyJobs reports JobState 0, the target's /items can still miss hundreds of files for a while; re-read until two reads match, and compare authors via $expand on /items, not siteusers |
| [Copy job by files: folder metadata is lost](rest-api/copy-job-item-batches-folder-metadata.md) | A CreateCopyJobs job that gets folder URIs carries folder metadata, a job fed with file URIs needs existing target folders, and folders created with AddUsingPath show the running account and the creation time until you rewrite Created/Modified/Author/Editor with ValidateUpdateListItem; the JobWarning 'Field TaxCatchAll cannot be found' comes from the automatic Image Tags column |
| [File time vs. item time, and ListItemAllFields dates](rest-api/listitemallfields-dates-are-web-local-without-z.md) | TimeCreated/TimeLastModified of a file can differ from the item's Created/Modified by seconds or years; copy jobs carry the item time, so compare items – and read them from /items(id) (UTC), because ListItemAllFields returns web-local time without Z |
| [/_api/hubsites is trimmed per user](rest-api/hubsites-list-is-trimmed-per-user.md) | `GET /_api/hubsites` returned 2 hubs to an admin and 1 to an ordinary user who had Full Control on the missing hub's site, so client code that discovers something by walking the hub list works for the editor and silently fails for readers; resolve it at authoring time and store the result in the web part properties |
| [StorageMetrics can be stale, not just zero](rest-api/storagemetrics-can-be-stale-not-just-zero.md) | zero = not calculated, but non-zero can be stale (261 files / 3.1 GB vs real 794 / 9.5 GB); trust it only when `TotalFileCount` exactly matches the files you counted |
| [Concurrent MERGE to one item: 409](rest-api/concurrent-merge-same-item-409-save-conflict.md) | `IF-MATCH: *` only switches off the ETag check (412); SharePoint still refuses a second save of the same item while the first is in flight with 409 Save Conflict. Queue writes per item (`list#id`), retry 409 briefly, never retry 412, and do not let a state-carrying write fail silently |
| [List item versions are newest-first](rest-api/list-item-versions-are-newest-first-and-include-current.md) | `items(id)/versions` is DESCENDING and its first row is the current version (`IsCurrentVersion: true`) – the opposite of file `/Versions`; `Created` on a version is UTC WITHOUT the `Z`, so `new Date()` shifts it by the browser offset – use `Modified`; `VersionId = major*512 + minor` |
| [Power Platform admin API nextLink goes to a regional host](rest-api/power-platform-admin-api-regional-nextlink.md) | Admin listings page through `unitedstates.api.powerapps.com` (and other one-label regional hosts); a token guard that allows only the global host rejects page two, and you never see it on a small tenant – allow exactly one DNS label in front of the three admin hosts, nothing else |
| [Retrying without new columns on any 400 loses data](rest-api/retry-without-new-columns-on-any-400-loses-data.md) | When one new column is missing, a group-wide retry drops the ones that exist too; drop only the fields the error names as not existing, loop, and tell the user what was left out |
| [`folders/AddUsingPath` in a generic list makes a folder without a list item](rest-api/list-folder-via-folders-add-has-no-list-item.md) | In a custom list (BaseTemplate 100) `web/folders/AddUsingPath` creates a 'naked' folder: `ListItemAllFields` is `odata.null` (`@odata.null` under SPFx's OData v4) and `breakroleinheritance` is 404. Create list folders with `AddValidateUpdateItemUsingPath` and `UnderlyingObjectType: 1`, then check the item state, not just `Exists` |
| [A `$filter` literal encoded twice finds nothing — with a 200](rest-api/filter-literal-encoded-twice-finds-nothing.md) | If a helper already percent-encodes the quoted literal, wrapping the whole `$filter` in `encodeURIComponent` encodes the value a second time (`/` → `%252F`, `@` → `%2540`); SharePoint then compares against the escaped text and returns an empty result, no error; encode in exactly one layer and test with a fake server that decodes once and really filters |

### lists/

| Gotcha | TL;DR |
|---|---|
| [The 5,000-item view threshold](lists/list-view-threshold-and-indexes.md) | It's scanned rows, not returned rows; index early, page always |
| [View formatting JSON can't contain `<` or `&`](lists/view-formatter-rejects-angle-bracket-and-ampersand.md) | It's stored inside the view's schema XML; reverse the comparison and nest `if()` |
| [An empty Date field is not `''`](lists/empty-date-is-not-an-empty-string-in-formatting.md) | Blank dates fall through to "overdue" and turn rows red; test `.displayValue` |
| [Gallery cards render from `tileProps`](lists/gallery-cards-render-from-tileprops.md) | The documented top-level `formatter` is ignored; the card lives in a nested, undocumented `tileProps.formatter`. Layout and card must go in two separate writes (PnP or REST/SPFx) |
| [Non-web protocol in `href` drops the whole element](lists/formatter-href-non-web-protocol-drops-element.md) | `ms-word:` in a formatter link removes the element with all children, per item, no error; open-in-app = `openContextMenu` |
| [`.html` in a library cannot be iframed](lists/html-in-a-library-cannot-be-iframed.md) | SharePoint serves library HTML as an attachment, so the iframe loads empty; host it elsewhere and allow that host in HTML Field Security |
| [Seed idempotency must key on the item](lists/seed-idempotency-must-key-on-the-item.md) | A per-SET presence check re-inserts the whole block; SharePoint cannot enforce a unique set + value pair, and two app starts at the same moment still race – after inserting, re-read and delete your own copies that are not the oldest |
| [A Choice value the field does not know is stored anyway](lists/choice-value-missing-from-the-field-is-stored-anyway.md) | No 400, no error to swallow: a drifted vocabulary never shows at runtime; machine-check the type-to-schema contract |
| [Version-gated provisioning drops elevated settings](lists/version-gated-provisioning-drops-elevated-settings.md) | A settings PATCH needs Manage Lists; the first member to open the app after an upgrade burns the one attempt and the version is stored anyway |
| [Item-level permission defaults on provisioned lists](lists/item-level-permissions-defaults-on-provisioned-lists.md) | ReadSecurity=2 looks perfect to an admin and empty to everyone else; WriteSecurity=2 breaks collaborative edits only |
| [View formatting lands on the wrong view](lists/view-formatting-lands-on-the-wrong-view.md) | Site Pages has no `AllItems.aspx` and the default view may be grouped; a column missing from the view is `undefined`, so the empty-check guard never hides it |
| [Created/Modified on a document library](lists/created-modified-on-a-document-library.md) | A plain MERGE reports 204 and SharePoint overwrites both with "now"; `ValidateUpdateListItem` + `bNewDocumentUpdate` is the only path that holds, it wants the web's locale format (the error message tells you which), reads it in the web's regional time zone rather than the browser's, truncates to the minute even when you send seconds, and drops `Author` without a word unless all four author/date fields arrive together |
| [Column internal name comes from DisplayName](lists/field-internal-name-comes-from-displayname.md) | `createfieldasxml` on a list ignores `Name`/`StaticName` unless you pass `Options: 8` (AddFieldInternalNameHint); otherwise the internal name is escaped from the label – create with ASCII and rename, or use the hint to keep a source name |
| [Indexing built-in fields (Title, Created)](lists/indexing-built-in-fields-title-created.md) | Provisioning indexes only the columns it creates, so the most-filtered fields stay unindexed; past 5,000 items the read throttles and a swallowed error in a write path stops all writes silently |
| [Reader-written counters belong in their own list](lists/reader-written-counters-belong-in-their-own-list.md) | Views and ratings are written by the reader, so on a `WriteSecurity: 4` content list every click is a swallowed 403 while the UI says thanks |
| [Item IDs are never reused — unless the list is recreated](lists/item-ids-are-not-reused-but-recreated-lists-restart.md) | Purging a list does not rewind its counter, so orphaned rows in a derived list are dead weight; a recreated list restarts at 1 and turns them into wrong-item attributions |
| [`length()` is for arrays, not strings](lists/formatting-length-is-for-arrays-not-strings.md) | String length via `indexOf(str + '^', '^')`, or the expression collapses to empty |
| [`MajorVersionLimit: 0` means unlimited, not none](lists/major-version-limit-zero-means-unlimited.md) | One integer covering "no limit", "keep n" and "versioning is off"; the library your report calls tidiest is the one eating the quota |
| [A version is a full copy, not a delta](lists/a-version-is-a-full-copy-not-a-delta.md) | The quota counts every version at full file size and a metadata-only edit costs one; measured on a live tenant, three column edits took a 1 MiB file to 4 MB |
| [View formatting drops CSS gradients](lists/view-formatting-drops-css-gradients.md) | `background` is not on the style allowlist and a gradient in `background-image` did not survive either; a white initial on a gradient avatar turns invisible, so use a solid `background-color` |
| [A formatter renders empty in the tab you are iterating in](lists/formatter-renders-empty-in-your-working-tab.md) | After many REST writes the working tab shows even valid formatters blank while a fresh tab renders them; one tab per version, a scratch view, and simple formatters on fragile lists |
| [Client-written identity vs. the server-stamped Author](lists/client-written-identity-needs-the-server-author.md) | Append-only lists let any contributor add a row with someone else's name in a text column; `Author` is stamped by SharePoint. Compare the two — but against `Author/Name` (the sign-in claim) as well, or every user whose e-mail differs from their UPN looks like a forger |
| [Group header date = reader's display string](lists/group-header-date-is-the-readers-display-string.md) | `@group.fieldData` of a Date field is text in the reader's locale; reformat only when the shape matches |
| [Multi-line text in a document library stops at 255 characters](lists/library-multiline-text-255-limit.md) | A REST MERGE stores 70k characters, but ValidateUpdateListItem rejects anything over 255 unless the column has UnlimitedLengthInDocumentLibrary – set it at creation or PATCH it later |
| [List items with authors, dates and history](lists/list-items-keep-authors-dates-and-version-history.md) | Copy and Move does not do lists, but REST does the job item by item — `AddValidateUpdateItemUsingPath` takes Author, Editor, Created and Modified at creation (one call, one version), version history can be replayed with `ValidateUpdateListItem(bNewDocumentUpdate:false)`, approval goes in the same call, attachments add a version, two field formats are dropped without any error, and a user who may only add items cannot set the four fields – SharePoint silently keeps the real author and time |

### permissions/

| Gotcha | TL;DR |
|---|---|
| [WriteSecurity 4 ignores Contribute](permissions/write-security-4-needs-managelists.md) | Only ManageLists (Design/Edit/Full Control) overrides the item-level write block, so "break inheritance + give the group Contribute" grants nothing; resolve the role by RoleTypeKind, not by its localised name |
| [PermissionKind is 1-based](permissions/permissionkind-is-one-based-off-by-one-fakes-a-finding.md) | The flag for kind *k* is `1 << (k - 1)`, so `1 << k` invents a security finding: Contribute appears to hold `ManageLists` (it holds `ViewFormPages`). Calibrate any decoder against a known level first, and note the tell — a decode that yields a role SharePoint does not have |
| [Breaking inheritance without copying keeps only you](permissions/break-without-copy-keeps-only-you.md) | `copyRoleAssignments=false` leaves exactly one role assignment (the caller), so re-granting Owners/Members/Visitors drops every direct grant and custom group; and an account that loses a *configuration* read can end up with that feature's limits switched off, so the hardening pass lowers security for the very account it locked out |
| [Limited Access cannot be granted](permissions/limited-access-cannot-be-granted.md) | `RoleTypeKind: 1` is derived, not assignable: SharePoint maintains it so people can traverse to a child with unique permissions, and an explicit `addroleassignment` returns 200 while doing nothing. Strip it from EVERY assignment when replicating a permission shape (not only where it stands alone), detect it by number rather than its localised name, and guard the write side too — a verification pass that keeps reporting non-problems teaches the reader to skim past the real one |
| [`HasUniqueRoleAssignments` proves the break, not the hardening](permissions/unique-role-assignments-is-not-proof-of-hardening.md) | The flag is `true` after the break alone, so verifying on it reports "hardened" over a list whose trim never ran; with the safe `copy=true` form that state is worse than inheritance. Read `roleassignments` and measure `BasePermissions` bits — role names are localised and `RoleTypeKind` misses a customer's own level |
| [A status code measures the order of checks, not permissions](permissions/status-code-measures-the-order-of-checks-not-permissions.md) | A probe that writes with a deliberately stale `IF-MATCH` and reads permissions off 403 vs 412 measures which gate the request hit first: SharePoint evaluates the etag BEFORE authorisation, so 412 comes back even where the account has no `EditListItems`. Ask `EffectiveBasePermissions`, run under an ordinary account, and print the inputs beside the verdict |
| [A one-sided permission check passes on an empty ACL](permissions/one-sided-permission-check-passes-on-an-empty-acl.md) | "no principal outside the allow-list can write" is also satisfied when nobody can write at all; assert the opposite direction with a non-zero writer count, and read the principals strictly so a transient 429 cannot silently drop a group out of the policy |
| [Approving Graph permissions for SPFx needs a Global Administrator](permissions/graph-api-access-approval-needs-global-admin.md) | API access requests for Microsoft Graph need Global Administrator (Application Administrator only covers third-party APIs), and admin consent for Graph *application* permissions such as `Sites.Selected` is explicitly excluded from Application/Cloud Application Administrator; put the role, with the citation, into the customer's preflight |
| [The tenant root site is not necessarily readable by everyone](permissions/tenant-root-site-is-not-readable-by-everyone.md) | Measured: unique permissions, no *Everyone except external users*, an ordinary employee's mask `High=0, Low=0`; don't anchor a must-work-for-everyone mechanism there, measure with `getusereffectivepermissions` |
| [A count over a ReadSecurity 2 list is only the reader's share](permissions/a-count-over-a-readsecurity-2-list-is-the-readers-share.md) | Item-level read security filters rows silently, so a client-side count or ranking is the reader's own rows; show it only with `ReadSecurity = 1` or Manage Lists in the reader's mask, and don't also demand Override List Behaviors — the Edit role (`0x3C431AEF`) lacks it |
| [Counting a site's audience from its associated groups silently misses nested groups](permissions/site-audience-from-associated-groups-misses-nested-groups.md) | Owners/Members/Visitors often contain *Everyone except external users* or the Microsoft 365 group claim (PrincipalType 4); keeping only PrincipalType 1 rows turns a whole company into one or two people – treat any nested group as 'audience unknown' |
| [Upload-only drop box for sensitive files](permissions/upload-only-drop-box-for-sensitive-photos.md) | Giving someone write access to a protected library folder also lets them read every file in it; a list with `ReadSecurity = 2`, Contribute for the senders and Edit for the reviewers lets people send files they alone can see — the direct attachment URL of someone else's item is denied in SharePoint Online (measured) |
| [Add-only permission sync leaks on owner change](permissions/add-only-permission-sync-leaks-on-owner-change.md) | Idempotent grant-only re-sync is safe at item creation but not when an item moves to another customer or team — the old group stays on the item; remove by principal Id, keep a protected set, and verify with a second roleassignments read |
| [Effective permissions ignore security groups](permissions/effective-permissions-ignore-security-groups.md) | Asked on behalf of another user, `GetUserEffectivePermissions` counts Microsoft 365 group membership but not Entra security group membership — directly assigned or nested in a SharePoint group, still after 40 s — so it reports 'no access' for people who do have it once they sign in. Ask with the group's claim instead (that works), union the roles reached through the user's transitive security groups, treat a mask without any content bit (Limited Access, even with extra bits) as no access, and drop NoScript-stripped bits (AddAndCustomizePages) before mapping a mask to a permission level |
| [Contribute on the web can edit and delete site pages](permissions/contribute-on-the-web-can-edit-and-delete-site-pages.md) | Modern pages are items in the Site Pages library, which inherits from the web, so a group you give Contribute on the web "so it can write list items" can also edit and delete every page; withholding Manage Lists does not help — break inheritance on Site Pages and give app roles Read there |
| [Guests cannot join Teams shared channels](permissions/guests-cannot-join-teams-shared-channels.md) | Shared channels admit external people only through B2B direct connect between two Entra tenants; a guest account cannot be added, so a partner without their own Microsoft 365 needs a different design |
| [Item edit rights cover every field](permissions/item-level-edit-rights-cover-every-field.md) | Unique permissions on an item don't separate fields: whoever may edit it can set Status, Approver or a lookup Id via REST – derive trust from admin-only data and version history |
| [FirstUniqueAncestorSecurableObject has no type in nometadata](permissions/first-unique-ancestor-has-no-type-in-nometadata.md) | The endpoint returns a web, a list or a list item depending on where inheritance breaks, and with `odata=nometadata` nothing names the type; classify by properties (`FileSystemObjectType` = item, `BaseTemplate` = list, otherwise web) and treat an unknown shape as restricted |
| [Narrowing to Read leaves the principal with nothing](permissions/narrowing-to-read-checks-view-before-stripping-write.md) | Edit and Contribute contain the View bit, so a "does it already read?" check passes, Read is never added, and the step that removes the write binding then removes the only binding that carried read access — the principal ends with no access and the routine reports unreconciled forever |
| [Page-only reader is denied at the site root](permissions/page-only-reader-is-denied-at-the-site-root.md) | When a principal has Read on a single page in Site Pages and only Limited Access on the site, opening the site URL ends on AccessDenied.aspx even though the page itself opens fine — so a link that falls back to the site root breaks exactly for the narrow audience it was meant for |
| [Restoring inheritance is a one-way door](permissions/reset-inheritance-is-a-one-way-door.md) | `resetroleinheritance` throws away every unique role assignment and SharePoint keeps no copy, so an "undo" is only possible if the code saved the assignments BEFORE the call; re-breaking with `copyRoleAssignments=true` gives you the parent's ACL, not the old one, so the undo has to reconcile (add missing, remove extra) against the snapshot — keyed by principal id and role definition NAME, skipping Limited Access |
| [Editing others' items needs Override List Behaviors](permissions/editing-others-items-needs-override-list-behaviors.md) | On a list with item-level write security, the Edit role (Manage Lists) still gets 403 when it updates an item someone else created; only a role with Override List Behaviors (`CancelCheckout`, Low 0x100) gets through, so moving a group from Full Control to Edit silently takes that ability away |

### spfx/

| Gotcha | TL;DR |
|---|---|
| [A zip-bomb guard on a private JSZip field fails open](spfx/zip-bomb-guard-reading-a-private-jszip-field.md) | `entry._data.uncompressedSize` is an implementation detail, and `undefined → allow` turns the next library upgrade into a silent loss of protection; read the declared sizes from the ZIP central directory instead |
| [Hand-built .docx / .pptx: the parts Office demands](spfx/hand-built-ooxml-missing-parts.md) | Header images are referenced from the header's own rels, Content_Types must know the image extension, namespaces are per part, and a .pptx without theme + master + layout is invalid; split parts from zipping so a strict XML parser can check them in tests |
| [Graph grants are tenant-wide](spfx/graph-permission-grants-are-tenant-wide.md) | `webApiPermissionRequests` is a request, not a grant; an approval lands on one tenant-wide principal, so a scope another solution had approved already works in yours (and may be missing at the customer) |
| [No "grant it for me" button in a web part](spfx/web-part-cannot-grant-its-own-graph-permissions.md) | A delegated call gets the intersection of the user's rights and the app's grants, so the admin at the keyboard adds nothing; writing the grant would take at least `DelegatedPermissionGrant.ReadWrite.All` approved for the principal every SPFx solution shares, and approval runs on the admin site. Give the admin the path instead: API access first, a script second, and what the approval does not guarantee |
| [Protocol-relative URL defeats a host allowlist](spfx/protocol-relative-url-defeats-a-host-allowlist.md) | A host check built on `^https?://` reads `//evil.example/…` as relative and lets it through; the browser then supplies the scheme and the request leaves for a foreign origin |
| [A test outside the release command guards nothing](spfx/a-test-outside-the-release-command-guards-nothing.md) | `heft build` runs no Jest and the two prebuild scripts drift, so a rule can look covered while never executing on the release path |
| [Polling the manifest proves nothing](spfx/static-host-serves-manifest-before-bundle.md) | A static host publishes the small manifest before the large entry bundle, so the component loads nothing and logs nothing; and an A/B against the previously deployed version compares deploy age, not code (+ a 503 on one file next to a 200 on its neighbour is the same deploy window) |
| [`Promise.all` discards the soft reads it was waiting for](spfx/promise-all-discards-the-soft-reads-it-was-waiting-for.md) | A failing main read rejects the batch and the soft reads' results and failure flags never reach the pickers; settle each read on its own |
| [An unparseable JSON column is not an empty column](spfx/unparseable-json-column-is-not-an-empty-column.md) | `catch → {}` turns "could not read" into "is empty" and the next save writes the emptiness back over the record; keep three states, refuse the write before the first PATCH, lock the form visibly, and make recovery an explicit action |
| [The tenant's CSP is inherited by `srcdoc` frames](spfx/tenant-csp-is-inherited-by-srcdoc-frames.md) | Modern pages send `script-src` without `'unsafe-inline'`, and a document you build yourself (`srcdoc`, `blob:`, `data:`) inherits it, so every inline script in the embedded HTML is dropped; an external file from an allow-listed source (including `'self'`) still runs |
| [Custom API permission request is "not valid"](spfx/custom-api-permission-request-invalid.md) | API access resolves `resource` to a service principal, so the app registration must exist before the `.sppkg` is uploaded |
| [A global kill-switch must not block its own fix](spfx/kill-switch-must-not-block-its-own-fix.md) | The lock defends itself against being lifted; infrastructure operations (version pin, re-check, config, diagnostics) belong outside it |
| [The ES2015 `lib` trap](spfx/es2015-lib-forbidden-apis.md) | TS2550 on `padStart` & friends, and the safe equivalents |
| [Dropping a folder does nothing](spfx/drag-drop-folders-webkitgetasentry.md) | `dataTransfer.files` cannot see a folder, `items` is neutered after the first `await`, and `readEntries()` returns the contents in batches of 100 |
| [Opening a pre-filled e-mail in the desktop client](spfx/open-email-in-desktop-client-eml.md) | No API tells you which mail client is default; `mailto:` reaches it but carries no attachment, and a `.eml` needs `X-Unsent: 1` or it opens read-only |
| [`window.open(…, 'noopener')` returns `null` even when the tab opened](spfx/window-open-noopener-returns-null.md) | Code that reads `null` as "blocked" runs its fallback every time: the e-mail opens twice (Outlook on the web and `mailto:`), the print window stays empty; open without the feature and set `w.opener = null` |
| [Instrumenting the Graph client fails silently](spfx/instrumenting-the-graph-client-fails-silently.md) | Reassigning `client.api` throws and your fail-safe catch hides it; use `Object.create` and assert it attached |
| [SPA router hijacks anchor clicks](spfx/spa-router-hijacks-anchor-clicks.md) | `<a href>` navigates before React `onClick` runs; use buttons for in-app actions |
| [`stopPropagation` in `onClick` does not stop the keyboard](spfx/stoppropagation-on-click-does-not-stop-the-keyboard.md) | A clickable row with `onKeyDown` also receives the Enter/Space of its inner buttons and checkboxes, so their action is cancelled (or runs twice) and the detail opens; react only when `e.target === e.currentTarget` |
| [Third-party CSS breaks webpack](spfx/css-url-assets-break-webpack.md) | `url(images/...)` without `./` kills the build; inject a `<link>` instead |
| [Minified React errors cheatsheet](spfx/react-minified-errors-cheatsheet.md) | #310/#300/#321/#31/#185 decoded for SPFx debugging |
| [People search endpoints that work](spfx/people-search-endpoints-that-work.md) | SP Search People source + `ensureuser`; why the obvious endpoints fail |
| [Fixed dropdowns in transformed panels](spfx/fixed-dropdowns-in-transformed-panels.md) | The CSS transform containing-block trap; portal to `document.body` |
| [`SP.WebProxy` is add-in-only](spfx/webproxy-is-add-in-only.md) | No SharePoint-native CORS proxy for SPFx; the 403 hides inside an HTTP 200 |
| [Teams personal app needs global deploy](spfx/teams-personal-app-needs-global-deploy.md) | `skipFeatureDeployment: true` + "all sites"; the `teams/` icon-folder convention |
| [Teams mobile webview renders desktop width](spfx/teams-mobile-webview-renders-desktop-width.md) | ~980px layout; fix the viewport meta in Teams first |
| [Shared component measures the viewport, not its container](spfx/shared-component-measures-viewport-not-container.md) | `@media` never sees the narrow panel you were reused in; measure the element with `ResizeObserver` and switch every child together |
| [CDN-hosted bundle still needs a new `.sppkg`](spfx/cdn-hosted-bundle-still-needs-new-sppkg.md) | `includeClientSideAssets: false` moves assets, not versioning; the manifest pins a content-hashed filename, so old packages never request the new bundle |
| [CDN pruning deleted the extension bundle the .sppkg still referenced](spfx/cdn-pruning-deletes-extension-bundles-the-sppkg-still-references.md) | With `includeClientSideAssets: false` the catalog pins hashed names for customizers and command sets too; a "keep N newest" prune evicts the referenced hash and the extension 404s for days — protect extension bundles by name pattern or give them stable names |
| [List pages run a stale extension bundle from Cache Storage](spfx/list-pages-run-stale-extension-bundles-from-cache-storage.md) | Stable (unhashed) bundle URL = list and library pages can run a days-old copy from the Lists app's own Cache Storage (`Lists-odsp-web-prod_*`), whatever `Cache-Control` says; keep the stable file a tiny loader and load real code from a versioned URL |
| [`npm audit --omit=dev` overstates shipped risk](spfx/npm-audit-omit-dev-overstates-shipped-risk.md) | SPFx keeps its Node build toolchain in `dependencies`, so 229 „production“ findings can hide a single real one; grep the published bundle and always include a control sample |
| [Office file extraction needs a decompressed-size cap](spfx/office-file-extraction-needs-a-decompressed-size-cap.md) | An upload-size limit only bounds the compressed archive; a crafted `!ref` still OOMs the tab |
| [A Promise over img.onload can hang forever](spfx/image-promise-without-a-timeout-hangs-forever.md) | onload/onerror are not guaranteed to fire; try/catch guards rejection, not a Promise that never settles |
| [Shared package's dynamic import ships inlined](spfx/shared-package-dynamic-import-inlines-with-commonjs.md) | A linked TS package built with module:commonjs turns import() into require(), so webpack can't lazy-chunk the lib into a separate file; set the package's module:esnext |
| [cultureInfo returns the web or the user language](spfx/culture-info-returns-web-or-user-language.md) | The same page loads English once and Czech the next time; cache the resolved language per web instead |
| [Centered flex clips on mobile](spfx/centered-flex-clips-on-mobile.md) | Centering + overflow cuts content above the scroll; flex "springs" |
| [JSX attributes and smart quotes](spfx/jsx-attributes-and-smart-quotes.md) | Typographic quotes in attributes = TS1003; wrap as `{'…'}` |
| [A conditional spread hides a phantom list column](spfx/conditional-spread-hides-a-phantom-list-column.md) | `...(cond ? {…} : {})` bypasses the excess-property check, so a field the list doesn't have reaches the POST and 400s; two scripted sweeps find the rest |
| [A shared component's var() fallback chain is only as good as its last link](spfx/shared-component-var-fallback-lands-on-system-font.md) | Shared UI package falls through to `sans-serif` in apps that never publish CSS custom properties; runtime-only, no build catches it |
| [Read-only mode must cover Graph too](spfx/read-only-mode-must-cover-graph-too.md) | An SPFx app writes through two clients; wrapping `SPHttpClient` leaves mail, calendar and Planner wide open |
| [Portaled overlays miss your CSS reset](spfx/portaled-overlays-miss-your-css-reset.md) | Portal to `body` escapes the scoped reset; `content-box` fields overflow and grow a scrollbar |
| [A theme override on your app root can't reach tokens declared on `:root`](spfx/theme-override-cant-reach-root-declared-tokens.md) | The accent inherits, everything derived from it doesn't; substitution happens where the token is declared, portals miss the override entirely, and a colour written as a literal hex is unreachable either way |
| [An element selector outranks your button class](spfx/element-selector-outranks-your-button-class.md) | `.app-root a { color: inherit }` beats `.btn--accent`, so link-styled buttons get an unreadable label — but only on a tenant whose accent needs white text |
| [`behavior: 'smooth'` is silently ignored inside a portaled overlay](spfx/smooth-scroll-ignored-in-portaled-overlay.md) | Jump-to-section does nothing in a modal/reader; drop the flag and set `scrollTop` directly |
| [Previewing library files in your own app](spfx/previewing-library-files-in-your-app.md) | PDF embeds directly, Office needs WOPI by `{UniqueId}`, HTML is served as an attachment and never renders from `src`; SVG and HTML must never be framed from your own origin (uploader script runs in the viewer session) and OneDrive refuses framing with no way to detect it; `?web=1` on a PDF ends on AccessDenied for users with access only to the file or its folder |
| [Auto-save must wait for async inputs](spfx/autosave-effect-must-wait-for-async-inputs.md) | Computed-from-async auto-save fires on mount with empty inputs and persists garbage; gate on a loaded flag |
| [Mermaid clips node text with a web font](spfx/mermaid-text-clipping-webfont.md) | Measure-before-load (FOUT) sizes boxes for the fallback font; use a system font stack |
| [rules-of-hooks false-positive from a JSX `&&` chain](spfx/rules-of-hooks-false-positive-from-jsx-chain.md) | A complex conditional in your render blames the *wrong* hook; extract it to a `const`. Second trigger: sheer component *size* — there extracting conditions doesn't help, splitting the component does. Third trigger: the recommended `const` extraction ITSELF — only block-by-block bisection from a clean HEAD finds the guilty line |
| [A cached dynamic `import()` caches the rejection too](spfx/cached-rejected-dynamic-import.md) | One chunk-load blip poisons the module-level promise for the whole session; reset it to null in `.catch` so the next call retries |
| [`jest.mock()` doesn't hoist under Heft](spfx/jest-mock-doesnt-hoist-in-heft.md) | Tests run over pre-compiled `lib-commonjs` without Babel, so a mock written below the imports lands after `require`; write it above the imports or use `moduleNameMapper` (and why only *some* sp-http suites die on `@msinternal/ecs-flight`) |
| [Application Customizer runs again in dialog iframes](spfx/application-customizer-runs-in-dialog-iframe.md) | A floating button rendered twice, the copy pinned to the dialog's corner; refuse nested browsing contexts before anything else (+ un-render path and why a body MutationObserver needs a definitive „no“) |
| [`speechSynthesis.cancel()` doesn't stop chunked reading](spfx/speech-cancel-doesnt-stop-chunked-reading.md) | The utterance chain re-queues itself from onend; epoch counter bumped BEFORE cancel(), and no onEnd from a superseded chain |
| [Application Customizer's floating UI disappears on SPA navigation](spfx/application-customizer-content-disappears-on-spa-navigation.md) | The button vanishes as you click around the site; `visibilitychange` never fires on in-page nav, so re-render on `navigatedEvent` (+ a `MutationObserver` backstop that stops re-reading configuration after a definitive 404/403 and never reacts to its own writes) |
| [Per-user data vanishes for admin accounts](spfx/user-email-is-empty-for-mailbox-less-accounts.md) | Mailbox-less accounts get an empty `pageContext.user.email`; rows save under an empty key, SharePoint stores empty text as NULL, and `$filter=Field eq ''` never matches it again |
| [Deep-link param appended to a page URL that already has one](spfx/deep-link-param-appended-to-a-page-that-has-one.md) | The address an admin pastes already carries your routing param, so appending makes it appear twice and `URLSearchParams.get` reads the OLD value; replace the key, never append |
| [serverRequestPath is not the page URL](spfx/server-request-path-is-not-the-page-url.md) | It is the last SERVER REQUEST path, so a modern list view can hand you `…/RenderListDataAsStream`; validate the shape before storing it as your app page |
| [Parser entry and collector must share one shape](spfx/parser-entry-and-collector-must-share-one-shape.md) | A branch that accepts `1)` but collects only `1.` spins forever on one line until out-of-memory; one shared regex, a no-progress contract and an outer backstop, each sufficient alone |
| [Sharing text via URL hits length limits](spfx/share-text-via-url-hits-length-limits.md) | "Send to Teams" (`/share?msgText`) dies with `AADSTS90015` on long answers and `mailto` silently won't open; cap the URL payload and put the full text on the clipboard |
| [Provisioning step gated on success runs forever](spfx/provisioning-step-gated-on-success-runs-forever.md) | A startup step needing ManageLists writes no marker for ordinary members, so the whole batch replays on every page load; the marker needs two states |
| [Setup marker written although a write failed](spfx/setup-marker-written-although-a-write-failed.md) | The success flag measured the id lookups, not the `addroleassignment` POST, so a 403 froze a site with groups that carry no permission level at all |
| [Command set button never appears](spfx/command-set-button-never-appears.md) | Two independent causes: uploading a new .sppkg registers the extension on no site by itself (register it at runtime, or deploy it tenant-wide with the all-sites tick), and `raiseOnChange()` re-reads `command.visible` without re-running `onListViewUpdated` |
| [Injecting your own column into the modern list grid](spfx/modern-list-grid-inject-custom-column.md) | An appended cell lands in the wrong row and shifts the table; rows are `display: contents`, so the cells are the grid items and every one carries explicit `grid-column`/`grid-row` |
| [Reading the modern list selection from the DOM](spfx/reading-list-selection-from-the-dom.md) | Only a `ListViewCommandSet` gets `selectedRows`; from the DOM take names only (the name is a button, not a link), resolve paths with one folder-scoped call, and take the real count from `clearSelectionCommand` because the view is virtualized |
| [A command set's commands are fixed in its manifest](spfx/command-set-commands-are-fixed-in-the-manifest.md) | `title`, `iconImageUrl`, `visible` and `disabled` change at runtime, new commands need a new `.sppkg`; ship one entry that opens your own menu, or reserve hidden slots (and keep their titles honest) |
| [A library component loaded from your own manifest fetches its lazy chunks from that manifest's folder](spfx/library-component-lazy-chunks-load-from-the-manifest-folder.md) | `SPComponentLoader.loadComponent` + a relocated manifest: without the `chunk.*` files next to the bundle every `import()` 404s on first use, which an open-the-app smoke test never exercises |
| [An SPFx chunk's file name is not its content](spfx/spfx-chunk-name-is-not-its-content.md) | SPFx builds with `realContentHash: false`, so a byte-identical chunk can get a new name (measured) and nothing rules out the reverse; deduplicate by bytes or git blob and never overwrite a published file |
| [An overwritten script with a stable name keeps serving the old code](spfx/overwritten-stable-bundle-keeps-serving-old-code.md) | `max-age=600` on GitHub Pages, a memory cache shared by same-site tabs and SharePoint's service worker; keep stable files thin, test with `cache: 'reload'` and every tab closed, and don't read `transferSize` of a cross-origin script |
| [The "without a token" counter measures every client](spfx/token-rollout-counter-measures-every-client.md) | You add Entra ID token validation to an API your SPFx solutions call, ship it in soft mode and wait for "calls without a token" to reach zero — it never does, because a shared npm package, a second solution, a diagnostic "check model" call and a secondary host such as a list-view command set call the same API without the header. Inventory callers by endpoint, not by app; create the token source once per app root and pass it as a value (a module singleton does not cross bundle boundaries); guard each client with a source-level test |
| [`heft --clean` leaves stale tests in `lib-commonjs`](spfx/heft-clean-leaves-stale-lib-commonjs-tests.md) | In SPFx projects built with Heft, Jest runs the compiled tests in `lib-commonjs`, but the rig's clean step deletes `dist`, `lib`, `release`, generated `.scss.ts`/`.resx.ts` files and parts of `temp` (and `jest-output` for the test phase) — never `lib-commonjs`. Delete, move or rename a test in `src` and its old compiled copy stays there and keeps running, even in `heft test --clean --production`. Delete `lib-commonjs` after moving or deleting files, or remove orphaned compiled files in a prebuild step |
| [Print layer must be a direct child of `<body>`](spfx/print-layer-must-be-a-direct-child-of-body.md) | One way to print a report from an SPFx web part is to hide everything in print (`body > * { display: none }`, so the SharePoint suite bar, navigation and footer stay out) and show only a dedicated print layer. If that layer is rendered inside the web part, it is a descendant of a hidden element and the printout is blank. Render the print layer with a React portal straight into `document.body` |
| [Teams user ID belongs to the host site](spfx/teams-personal-app-user-id-belongs-to-root-site.md) | `legacyPageContext.userId` in a Teams personal app is the tenant root site's ID; written as the user's ID on another site it can silently point to someone else |
| [Docking a side panel on the newer SharePoint layout](spfx/docked-side-panel-new-sharepoint-layout-has-no-body-container.md) | A Copilot-style side panel narrows the SharePoint page by shrinking `.sp-App-bodyContainer`. On the newer page chrome (left app bar with Discover/Publish/Build) that container does not exist, so the docking code silently falls back to a floating panel with gaps around it. Dock against `#spPageChromeAppDiv` instead, and shrink it with `margin-right` – it is a flex item, so `width` has no effect. |
| [M365 roadmap API has no CORS](spfx/m365-roadmap-api-has-no-cors.md) | `releasecommunications/api/v1/m365` answers 200 without `Access-Control-Allow-Origin`; proxy it server-side |
| [pdf.js refuses two renders into one canvas](spfx/pdfjs-same-canvas-multiple-render.md) | A React effect that re-renders a PDF page on every resize starts a second render() before the first finishes; cancel the running task in cleanup and chain renders so they never overlap |
| [Extension bundle imports a module the manifest lacks](spfx/cdn-hosted-extension-bundle-imports-module-the-manifest-lacks.md) | Replacing an extension's stable-named bundle on the CDN without a new .sppkg works only while its `define([...])` lists modules already in the installed manifest's `scriptResources` — one new value import and the extension silently never starts |
| [DecompressionStream and trailing bytes in PDF streams](spfx/decompressionstream-trailing-bytes-pdf-streams.md) | Inflating a PDF FlateDecode stream in the browser fails completely because a line break sits between the data and `endstream`; trim trailing CR/LF and read chunk by chunk, keeping what arrived before the error |
| [Audio in SPFx/Teams must be optional](spfx/audio-playback-in-spfx-and-teams-must-be-optional.md) | SPO's CSP has no media-src today, so MP3 from your CDN plays; start playback from a click and fall back to silent timing on NotAllowedError, 404 or mute |
| [Power Automate admin API is unreachable from SPFx](spfx/power-automate-admin-api-unreachable-from-spfx.md) | SharePoint's token for Microsoft Flow Service carries only Microsoft's preauthorized scopes, so an approved `User` grant never reaches the token and the tenant-wide flows list stays 401 |
| [`window.scrollTo` does nothing in an SPFx web part on a modern page](spfx/window-scrollto-does-nothing-on-modern-page.md) | The page scrolls inside a canvas element, not the window; switching views in a single-page web part leaves the user mid-page. Find the real scrolling ancestor and reset that |
| [Shared style tag – the first web part wins](spfx/shared-style-tag-first-web-part-wins.md) | A library that injects `<style id=…>` only if the id is missing lets whichever bundle renders first decide the CSS for every web part on the page, so a fixed rule in a newer bundle can silently lose to an older copy that rendered first |
| [A textarea jumps after you change its text from code and restore the selection](spfx/textarea-jumps-after-programmatic-selection.md) | focus() scrolls the page to the element and, in Chromium, setSelectionRange scrolls the textarea to the end of the selection; restore the selection with focus({ preventScroll }) and put scrollTop back |
| [A trailing-whitespace regex freezes the page when it parses AI output](spfx/trailing-whitespace-regex-is-quadratic-on-ai-output.md) | /\s+$/ and lazy (.+?)\s*$ are quadratic on a line with a long run of spaces that does not end the line; text from an AI model or a foreign document is untrusted input, so trim with trimEnd() and anchor captures on a non-space character |
| [SPFx adds Subresource Integrity to a script when the manifest path is { path, integrity }](spfx/spfx-sri-integrity-via-manifest-path.md) | A component manifest you pass to SPComponentLoader.loadComponent can carry an SRI hash per script resource as path { path, integrity }; SharePoint then emits script integrity crossorigin=anonymous and the browser refuses tampered bytes – pin the bootstrap the same way from the .sppkg manifest (immutable CDN file), because includeClientSideAssets puts it in ClientSideAssets that guests cannot read; plus three traps that make console tests lie (id+version cache, requirejs path cache, CRLF in the git working copy); on list and library views SharePoint applies none of it, so a ListView Command Set or Application Customizer there runs unverified by the browser |
| [Load an SPFx bundle's lazy chunk from the browser console — no clicking through the UI](spfx/load-a-lazy-chunk-from-the-console-via-the-webpack-runtime.md) | Push a fake chunk with a runtime callback into the bundle's `webpackJsonp_<id>` array to get its real `__webpack_require__`, then `req.e(chunkId)` loads the chunk exactly as the app would — for checking 404s, SRI `integrity` or CSP on features you would rather not trigger |
| [Manifest SRI does not cover scripts your SPFx bundle loads itself (pdf.js, tesseract.js, workers)](spfx/spfx-sri-scripts-your-bundle-loads-itself.md) | Pinning the loader in the .sppkg and giving every manifest path and webpack chunk an integrity hash still leaves a hole – third-party libraries your own code injects with a script tag, fetches for a worker or that a worker pulls in with importScripts run unverified; keep their SRI hashes in the already verified bundle, set the integrity attribute (not the property), fetch worker code with integrity into a blob, and for tesseract.js 5 concatenate core and worker into one blob so importScripts is skipped |
| [Two "Uncaught (in promise)" from sp-pages-assembly on a modern page are SharePoint's own, not your web part](spfx/sp-pages-assembly-uncaught-in-promise-noise.md) | On SharePoint Online modern pages the console can show two "Uncaught (in promise)" exceptions with reason undefined and a stack only inside sp-pages-assembly, on every load and on SPA navigation – they also appear on a page with no custom code at all, so prove it with an A/B against such a page before you debug your own loader or web part; a page whose application customizer hides the SharePoint chrome may not show them, which makes "only some of my apps have it" look like a bug in those apps |
| [SPFx calling an intranet API hits Local Network Access](spfx/spfx-calling-an-intranet-api-hits-local-network-access.md) | A `*.sharepoint.com` page fetching a host that resolves to a private IP is a local network request – Chrome (from 142) and Edge prompt every user and a refusal is a bare `Failed to fetch`; pre-allow the SharePoint origin by policy and still plan for CORS, trusted HTTPS, VPN-only reach and per-user tokens – or let a connector inside the network write to a list through `Sites.Selected` |
| [Hosted workbench restores web parts on another site](spfx/hosted-workbench-restores-web-parts-on-another-site.md) | The hosted workbench keeps its canvas in sessionStorage per tenant origin, not per site; open another site's workbench in the same tab and your web part boots there — with that site's pageContext, so first-run provisioning creates lists on the wrong site. Clear `sp_webpart_workbench_state` before switching sites. |

### app-catalog/

| Gotcha | TL;DR |
|---|---|
| [Three `.sppkg` packaging pitfalls](app-catalog/sppkg-packaging-pitfalls.md) | ASCII-only solution name, icon exactly 96×96, Publisher column is AppSource-only |

### graph/

| Gotcha | TL;DR |
|---|---|
| [`/me/sendMail`: From is always the signed-in user](graph/sendmail-from-is-the-signed-in-user.md) | Delegated `Mail.Send` can't impersonate; configure Reply-To instead |
| [A `mailto:` fallback reported as sent](graph/sendmail-fallback-reported-as-sent.md) | `sendMail` fails on accounts without a mailbox and the fallback only hands a draft to a mail client; model sent / handed off / failed, never a boolean |
| ["0 sent" hides whether anyone was asked](graph/zero-sent-hides-whether-anyone-was-asked.md) | The recipient side of the same trap: a denied group read becomes an empty audience, an empty audience becomes a benign `false`, and an approval queue notifies nobody |
| [A newsletter looped over `/me/sendMail` is not bulk e-mail](graph/newsletter-loop-over-sendmail-is-not-bulk-email.md) | Per-mailbox and tenant limits, bulk mail through Microsoft 365 "isn't a supported use", JSON `sendMail` can't set `List-Unsubscribe`, ACS Email retires 2028; keep Graph for personal batches, send newsletters through an ESP via a server-side connector |
| [A pasted screenshot is too big for `/me/sendMail`](graph/sendmail-attachment-size-ceiling.md) | Inline base64 attachments share a ~4 MB request budget; shrink bitmaps in a canvas, cap the total, and remember `mailto:` fallbacks carry no attachments at all |
| [ApplicationAccessPolicy rejects a shared mailbox](graph/application-access-policy-rejects-shared-mailbox.md) | "not a security principal"; scope app-only `Mail.Send` to a mail-enabled security group |
| [`/me/todo` needs an Exchange mailbox](graph/todo-requires-exchange-mailbox.md) | 404 "Item not found" on admin/cloud-only accounts is not a 403; Planner works without a mailbox |
| [A mail-permission probe that can't tell "no mailbox" from "no consent" lies to admins](graph/mail-probe-no-mailbox-vs-no-consent.md) | `/me/messages` 404 `MailboxNotEnabled*` on mailbox-less accounts ≠ missing consent; three verdicts, and never cache a negative probe result |
| [Purview Audit Query API is async](graph/purview-audit-query-api-is-async.md) | Hour-long queries; attach to the last succeeded, create in background |
| [`dont have any permissions` rejects the user, not the app](graph/audit-query-permission-vs-audit-role.md) | Consent vs. Exchange audit role; Global Admin usually inherits it, so compare accounts |
| [A Teams channel exists immediately — its folder does not](graph/channel-created-but-its-sharepoint-folder-is-not.md) | `POST /channels` returns 201 while SharePoint provisions the folder minutes later; never hand-make the folder, report the pending state and let an idempotent rerun finish it |
| [Office files: property demotion changes the hash](graph/office-files-property-demotion.md) | Metadata PATCH and copy jobs rewrite Office, .msg and .htm file bytes; key change detection on `lastModifiedBy`, never compare sizes exactly |
| [Usage reports in the browser — use beta JSON](graph/usage-reports-cors-blocked-in-browser.md) | v1.0 302 to CSV dies on CORS; beta `?$format=application/json` returns 200 from Graph; mind roles and concealed names |
| [Tenant-wide enumeration is app-only](graph/tenant-wide-enumeration-is-app-only.md) | `getAllSites` rejects delegated tokens; check the Permissions table first |
| [`PATCH /me`: directory vs profile fields](graph/patch-me-directory-vs-profile-fields.md) | Mixed field groups fail whole; two PATCHes, profile best-effort |
| [az CLI can't grant Sites.Selected](graph/az-cli-cannot-grant-sites-selected.md) | `sites/{id}/permissions` needs `Sites.FullControl.All`; the CLI client can't request it (`AADSTS65002`), use the Graph PowerShell SDK |
| [`MSGraphClient` calls bypass DevTools Network](graph/msgraphclient-calls-bypass-devtools-network.md) | SPFx Graph traffic invisible in Network; diagnose via `performance` entries, `currentuser`, DOM, user's response |
| [preAuthorizedApplications needs an existing scope](graph/preauthorized-app-needs-existing-scope.md) | Graph checks `delegatedPermissionIds` against stored scopes; scope and its pre-authorization need two separate `PATCH` calls |
| [Re-read fresh before bulk-removing members](graph/reread-fresh-before-bulk-membership-removal.md) | The roster you showed is a preview; strict fresh re-read before a `removeMember` loop, read-error aborts, vanished member = idempotent skip |
| [`perUserMfaState` says "disabled" under Conditional Access](graph/per-user-mfa-state-lies-under-conditional-access.md) | The legacy per-user switch stays off while MFA is fully enforced; report on `userRegistrationDetails` instead — which omits blocked accounts and needs an undocumented Entra ID P1/P2 tenant |
| [Someone else's group membership without `User.Read.All`](graph/check-membership-of-another-user-without-user-read-all.md) | `/me/checkMemberGroups` only answers for you; ask the GROUP for its transitive members instead, and never turn "could not determine" into "not a member" |
| [The Graph JS client sends `$filter` values unencoded](graph/graph-client-does-not-encode-filter-values.md) | `.filter()`, `.query()` and `.api('…?…')` pass the value into the URL as-is (SDK 3.0.2, also under SPFx `MSGraphClientV3`): `#` cuts the query, `&` splits it and `.api()` even moves `$top` into the filter; double apostrophes, then `encodeURIComponent` |
| [directoryObject collections reject `$select` — and `$top` separately](graph/directoryobject-collections-reject-select-and-top.md) | User fields on `/members`/`/transitiveMembers` = 400; OData cast cures `$select` everywhere, `$top` stays per-endpoint (probe both) |
| [`$filter` on group members needs `$count=true` too](graph/filter-on-group-members-needs-count-too.md) | The header is only half the recipe; a permanently failing call hid behind a best-effort catch as a "temporary outage" |
| [Purview audit `objectIdFilters` can return nothing — silently](graph/purview-objectidfilters-returns-nothing-silently.md) | Narrowed to one library: 0 records, no error; without the filter 1,000 records, 38 of them for that library — ask for a window, filter by object locally, A/B every server-side filter |
| [A comma in a Graph permissions table can mean AND](graph/comma-in-a-permissions-table-can-mean-and.md) | Searching `chatMessage` needs `Chat.Read` **and** `ChannelMessage.Read.All`; the 403 spells it out, the table does not (+ `webUrl` vs `webLink` from the same endpoint) |
| [E-mail HTML built like a web page falls apart in Outlook](graph/email-html-built-like-a-web-page-breaks-in-outlook.md) | Classic Outlook renders with Word: no flexbox, rounded corners or gradients, so white text on a gradient vanishes; tables, solid colours, literal values, a full document, and under ~102 KB for Gmail |
| [signInActivity without P1 reports zero inactive users](graph/signinactivity-without-p1-reports-zero-inactive.md) | Selecting `signInActivity` on `/users` fails with 403 in tenants without Entra ID P1/P2; the common fallback (retry without the property) makes every user look "never signed in" or gets skipped, so inactive-user and unused-licence reports silently show 0 — store an explicit "not measured" state instead |
| [To Do rejects `$select` (400 ParseUri)](graph/todo-rejects-select.md) | Since Oct 2026 `/me/todo/lists` and `/tasks` return 400 for any `$select` except `id`; drop `$select`, keep `$top`/`$filter`, and measure 400s call by call before trusting a docs-based theory |
| [Graph client network failure = `statusCode: -1`](graph/msgraph-client-network-failure-looks-like-http-error.md) | msgraph client wraps a failed `fetch` as `GraphError` statusCode -1, code `TypeError` – detect it, don't report "permission denied" |
| [serviceAnnouncement/messages stops at one page](graph/service-messages-orderby-stops-at-first-page.md) | `messages?$top=100&$orderby=…` returned 100 posts without `@odata.nextLink`; `$top` + `$skip` returned all 441 |
| [Group `members/$ref` needs object ID, not UPN](graph/group-member-ref-needs-object-id.md) | `POST /groups/{id}/members/$ref` and `DELETE /groups/{id}/members/{id}/$ref` accept only the user's object ID (GUID); a UPN from a people picker returns 400 Invalid object identifier — resolve it with `GET /users/{upn}?$select=id` first |
| [Dynamic `memberOf` rules freeze after 3 Nov 2026](graph/dynamic-group-memberof-rule-retired-groups-freeze.md) | Entra ends the `memberOf` preview operator in dynamic membership rules on 3 Nov 2026 (MC1448379): affected groups, dynamic admin units and auto-assignment policies keep their last membership and never update again. Offboarding tools that skip dynamic groups leave departed users inside them. The notice covers only the rule operator, not the Graph `/memberOf` endpoints. |
| [Archived site returns 423 on every read](graph/archived-site-returns-423-on-every-read.md) | `archivalDetails` is unreadable once archived; `POST /sites/{host},{siteCollectionId}/unarchive` still works |
| [isPrivileged is beta-only](graph/privileged-roles-isprivileged-is-beta-only.md) | Read `isPrivileged` live from beta `roleDefinitions` – v1.0 omits it and 400s on `$select=isPrivileged`; Exchange/SharePoint/Teams admins are not privileged |
| [Channel messages need a Teams license on the reader](graph/teams-channel-messages-need-a-teams-license.md) | Delegated `GET /teams/{id}/channels/{id}/messages` returns 403 'Failed to get license information for the user' for an unlicensed account even with ChannelMessage.Read.All consented and team membership; a licensed member gets 403 UnknownError on a private channel it is not in; `allChannels` still lists channels of teams the admin is not a member of |
| [Audit query filters silently by scope](graph/audit-query-silently-filters-by-scope.md) | With only AuditLogsQuery-SharePoint.Read.All, Exchange and Entra queries return 201 → succeeded → 0 records; an unfiltered query returns only SharePoint records |
| [Exchange Admin API — no CORS from SPFx](graph/exchange-admin-api-no-cors-from-browser.md) | adminapi/beta/{tenant}/InvokeCommand fails with 'Failed to fetch' even with a fake token; read Exchange config with an admin-run script and import, or use Graph message trace and mailbox reports |
| [Disabled accounts include shared mailboxes](graph/disabled-accounts-include-shared-mailboxes.md) | blocked members ≠ former employees; tell shared mailboxes apart via the mailbox usage report's `recipientType` (needs real names) or `mailboxSettings.userPurpose` (per user) |
| [Teams message sent as the user doesn't notify the sender](graph/teams-message-sent-as-user-does-not-notify-sender.md) | Delegated `ChatMessage.Send` delivers (even to the self chat `48:notes`), but Teams never alerts the sender; verify with GET + `webUrl`, notify from the app |
| [sendMail without a mailbox – 504 and four attempts](graph/sendmail-without-mailbox-times-out-and-sdk-retries.md) | No `MailboxNotEnabled` error, just a 504 after ~30 s; the SDK RetryHandler repeats the non-idempotent POST up to 3×, so the button hangs ~2 min and a late success can send duplicates; check `assignedPlans` for an enabled `exchange` plan first |
| [PUT /groups/{id}/team on a new group returns 404](graph/create-team-on-new-group-returns-404-retry.md) | `PUT /groups/{id}/team` fails with 404 for minutes after `POST /groups` (replication); retry only 404, surface a real failure as a warning |
| [Suite incidents (MO…) slip past a per-service filter](graph/service-health-suite-incidents-have-no-workload.md) | Filtering `serviceAnnouncement/issues` by `service` dropped MO1550821 (12 Nov 2026, users unable to connect to multiple Microsoft 365 services) — its service was `Microsoft 365 suite` |

### azure-functions/

The standard server-side companion of an SPFx solution — and its own set of traps.

| Gotcha | TL;DR |
|---|---|
| [Windows zip deploy breaks the running app](azure-functions/windows-zip-deploy-breaks-running-app.md) | Live `wwwroot` file locks → whole app 503; re-run the deploy, prevent with `WEBSITE_RUN_FROM_PACKAGE=1` |
| [Verifying CORS by header presence passes every origin](azure-functions/verifying-cors-by-header-presence-passes-every-origin.md) | The header comes back for every origin (servers answer with a fallback, not an omission), so presence proves nothing; compare it to the `Origin` you sent, keep at least one case that expects *allowed*, and point the probe at a nonexistent route to prove it can fail |
| [A server-side origin check rejects your own smoke test](azure-functions/server-side-origin-check-rejects-your-own-smoke-test.md) | Once the allowlist is enforced in the handler it applies to every caller, so the deploy script's own verification and the runbook command both need the `Origin` header; "this test bypasses CORS" stops being true and hands the customer a red error on a healthy deployment |
| [Rate limit counts the capability probe](azure-functions/rate-limit-counts-capability-probe-corporate-nat.md) | Corporate NAT makes per-IP per-company; metered probes silently hide the feature |
| [A pinned Azure OpenAI model+version is a time bomb](azure-functions/azure-openai-pinned-model-version-is-a-time-bomb.md) | "Deprecating" blocks NEW deployments well before retirement; resolve the newest GA version at deploy time, and match the deployment name to the model family |
| ["No such host" for `<app>.azurewebsites.net`](azure-functions/unique-default-hostname-no-such-host.md) | New apps get a unique default hostname (`<app>-<hash>.<region>-01`); the bare name never resolves, and the deploy log's `.scm.` URL reveals the real host even when the app name is a masked secret |
| [Measure a third-party API before you build on it](azure-functions/third-party-api-measure-before-you-build-on-it.md) | The best-known free API returned 404/502/timeout on 7 of 8 live calls while the test suite stayed green; probe latency and status on real data, then cache successes (never failures) and honour `Retry-After` — a Function App's single outbound IP shares one quota |
| [Cache-Control on a dynamic endpoint is uninvalidatable](azure-functions/cache-control-on-dynamic-endpoint-is-uninvalidatable.md) | `max-age` on a live aggregate lets a shared cache hold the response keyed by URL; neither an app restart nor deleting the underlying data clears it, only expiry — serve dynamic data `no-store`, cache server-side, and have the consumer call with `?_=Date.now()` |
| [A brand-new Azure subscription blocks your first Function App deployment](azure-functions/fresh-subscription-blocks-your-first-deployment.md) | New subscriptions start with `Y1` quota 0 and unregistered resource providers; the quota is per subscription **and per region**, so try a neighbouring region first — `az appservice list-locations --sku Y1` lists regions you cannot deploy into, but `az deployment group validate` with a swapped `location` measures it for real — register providers up front, and turn the ARM error into a diagnosis (self-service quota requests can be auto-rejected, so don't promise minutes) |
| [Azure OpenAI 429: measure prompt size, not frequency](azure-functions/aoai-429-measure-prompt-size-not-frequency.md) | One request whose prompt exceeds the deployment's TPM allowance can never fit the window, so retrying is futile — a sub-second rejection means "over the ceiling", not "busy"; separate the two with a small/large request pair, and remember that TPM costs nothing on pay-per-token SKUs, so an undersized default breaks traffic without saving a cent |
| [Retry only fast failures — a gateway error after 230 s is a timeout](azure-functions/retry-only-fast-failures.md) | The 502/503/504 a client rightly retries for transient failures also comes back when a request ran out of time (Azure Load Balancer returns 502 after 230 s), and retrying that multiplies the wait (3 × ~230 s) and the bill (Azure OpenAI charges for processing even when the request ends in an error) — decide by how long the failed attempt took, and give the server its own deadline below the platform limit with a status clients do not retry |
| [`Retry-After` does not survive CORS](azure-functions/retry-after-does-not-survive-cors.md) | A cross-origin response exposes only the headers named in `Access-Control-Expose-Headers` plus a short safelist, so an API that sends `Retry-After` and a client that reads it still meet at `null` — both ends look right and a grep finds nothing; verify from the browser (never `curl`) and guard the list with a test over the build, proven by sabotage |
| [pdf-parse and pdfjs-dist cannot share a process](azure-functions/pdf-parse-and-pdfjs-dist-cannot-share-a-process.md) | Loading pdfjs-dist breaks pdf-parse's bundled pdf.js 1.10 ("bad XRef entry"), but only for classic xref-table PDFs and from the second request on — one PDF library per process; plus: load code under test with the loader production uses (createRequire, not `await import`) |
| [New GitHub repos send an OIDC subject with immutable IDs](azure-functions/github-oidc-immutable-subject-claim.md) | New GitHub repositories send the OIDC subject as `repo:owner@ownerId/repo@repoId:…`, so an Azure federated credential written in the classic `repo:owner/repo:…` form never matches and azure/login fails with AADSTS700213. Read the repository's `sub_claim_prefix` from the OIDC REST endpoint and build the subject from it |
| [Unanchored `.funcignore` patterns strip your dependencies](azure-functions/funcignore-unanchored-pattern-strips-node-modules.md) | Azure/functions-action applies `.funcignore` with gitignore semantics to every path in the package, so `src/` or `test/` without a leading slash also empties those folders inside node_modules. A dependency whose entry file lives in `src/` (debug, for one) fails to load, the worker registers no functions and the function routes return 404. Anchor root patterns with a leading slash |
| [Fixed `WEBSITE_CONTENTSHARE` + slots = production back on the old share](azure-functions/website-contentshare-fixed-value-breaks-after-slot-swap.md) | On a function app with deployment slots, `WEBSITE_CONTENTSHARE` isn't slot-sticky: a swap moves it together with the code. A template that pins the production value (say `toLower(appName)`) would point production back at its pre-swap share, which the staging slot now uses, the next time you deploy it. Leave the setting out of the template, as Microsoft recommends |
| [Azure OpenAI deployment type decides where prompts are processed](azure-functions/aoai-deployment-type-decides-where-prompts-are-processed.md) | A GlobalStandard deployment in an EU region may process prompts in any Azure region; only Data Zone (EU Data Boundary) or regional Standard keep processing in the zone or geography — write data-location claims from the SKU your script actually creates, and print it |
| [Public-mode flag opens every endpoint, not just the chat](azure-functions/public-mode-flag-opens-every-endpoint.md) | One Function App serves a signed-in SPFx app and an anonymous website chatbot; the website instance switches off the token check, and every other HTTP function in the same app (usage stats, storage read/write, file parsing) quietly becomes anonymous too — gate each non-public endpoint on the mode flag and enforce it with a test that scans every app.http registration |

### search/

| Gotcha | TL;DR |
|---|---|
| [Search ignores unknown managed properties](search/unknown-managed-properties-fail-silently.md) | A made-up property name returns HTTP 200 with full results; auto-created Choice properties are not queryable; probe with a fake name, map to RefinableString |
| [Site missing from the index looks like a permissions problem](search/site-missing-from-the-index-looks-like-a-permissions-problem.md) | Three queries tell you whether the site is in the index at all, before you debug a query that was never wrong |
| [Enumerate every site from the browser console](search/enumerate-every-site-from-the-browser-console.md) | `contentclass:STS_Site OR STS_Web` over Search REST lists every web the account can read, no admin role or tooling; plus the traps of a cross-site F12 sweep (Hidden filter, case-sensitive GetList, 400 vs 404, per-web digest) |
| [The crawl log DOES exist in SharePoint Online](search/crawl-log-exists-in-spo-via-csom.md) | Reachable only through CSOM DocumentCrawlLog via /_vti_bin/client.svc/ProcessQuery, gated by a separate Crawl Log Permissions grant that no admin role implies; returns ~52 columns incl. per-pass crawl timestamps, errors, NoIndex and delete state |
| [ViewsX properties sort only by `ViewsLifeTime`](search/viewsx-properties-sort-only-by-viewslifetime.md) | Windowed counts select but don't sort; re-rank client-side |
| [Compare SharePoint paths decode-first](search/compare-sharepoint-paths-decode-first.md) | Normalize encoding + boundary-aware prefix, or matches never fire |
| [Don't trust the parsed-file-types table: SPO does index `.md`](search/md-is-fulltext-indexed-despite-the-docs.md) | The official table omits Markdown, yet live SPO full-text indexes it; probe before architecting |
| [Graph Search returns 0 hits — you passed the question as the `queryString`](search/graph-search-raw-question-returns-nothing.md) | Translate question→keywords; "what's new" is a listing (`*` + default date sort, custom sort = 400); per-entity KQL only as documented |
| [An unparenthesized `OR` silently escapes your scope filter](search/kql-or-escapes-your-scope-filter.md) | KQL AND binds tighter than OR; wrapping the query in parens is necessary but NOT sufficient (text can break out of them), and an empty custom path list must not fall back to the whole tenant |
| [Sensitivity labels in Search — property works, licensing gates it](search/sensitivity-labels-in-search-and-licensing.md) | `InformationProtectionLabelId` returns a GUID only after AIP-enable + label + crawl; unlicensed tenants can't even create a label (`InvalidLicenseException`) |
| [`NoCrawl` on a library silently blinds your RAG](search/nocrawl-on-a-library-silently-blinds-your-rag.md) | Excluded containers return 0 with HTTP 200 and no log, so Search-backed AI never sees them; provisioning that excludes *data* lists must not inherit that onto *content* libraries; reindex can't be forced over REST (`SP.PropertyValues` won't MERGE); indexing is not a permission change — Search trims per user; and `Hidden` is a separate axis from `NoCrawl`, so hiding a library does not take it out of the index (measured before/after) |
| [Duplicate trimming hides the file copies you search for](search/duplicate-trimming-hides-file-copies.md) | Search collapses identical content by default, so a copy-finder returns "no copies" exactly when perfect copies exist; `trimduplicates=false` on duplicate-hunting queries only |
| [Search cannot see text inside images — and what that does to RAG](search/search-cannot-see-text-inside-images.md) | The crawler indexes the text layer only, so a value that exists only in an embedded flyer or a scan can never be found by keyword: a search-then-read pipeline is in a closed loop, the LLM re-ranker drops such documents (it scores text-layer snippets), and the only real fix is transcribing images into a crawled column |
| [`ParentLink` from Search points at a library view](search/parentlink-points-at-a-view-not-the-library.md) | Often `…/Forms/AllItems.aspx`, so used as a path it empties a `Path:` filter and can send a save into `Forms` while a `GetList` probe still passes; normalize on read and on write |
| [RAG finds nothing — a reasoning model used the token cap for thinking](search/reasoning-model-empty-query-falls-back-to-the-raw-question.md) | GPT-5/o-series count hidden reasoning in max_completion_tokens; a small cap on the query-generator call returns empty text (HTTP 200), and a raw-question fallback ANDs every word in KQL |
| [Inflected-language queries hit 0 — use `stem*`, and zero hits is not a listing](search/sp-search-inflected-languages-need-prefix-wildcards.md) | KQL is implicit AND and matches exact word forms, so Czech inflected forms miss; add a `stem*` variant in code, relax before giving up, and never serve a date-sorted inventory as sources for a specific question |
| [`Size>=a Size<=b` returns everything — same-property restrictions are OR-ed](search/kql-same-property-restrictions-are-ored.md) | KQL joins restrictions on the SAME managed property with OR when only a space separates them, so a size or date range written as two restrictions matches every item; write ranges with explicit AND or a..b |
| [`SPSiteUrl:or("x")` returns 0 — scope Search to a site list via postquery RefinementFilters](search/scope-search-to-a-list-of-sites-refinementfilters.md) | To restrict any Search query to an explicit list of site collections, POST to /_api/search/postquery with RefinementFilters SPSiteUrl:or(...) (tested with 3,000 URLs); a single value must use equals() because or() with one value returns zero rows; and LastModifiedTime of an STS_Site row says nothing about activity on the site |
| [Search 500 / 429 without Retry-After are transient — retry the read](search/search-500-and-429-are-transient-retry-reads.md) | SharePoint Search REST can fail for a few minutes with HTTP 500 SearchServiceException (no Retry-After) and HTTP 429 low-resources errors; the same query succeeds seconds later, so retry reads briefly with a fixed backoff and show a truthful error with a Try again button |

### powershell/

| Gotcha | TL;DR |
|---|---|
| [PS 5.1 `Get-Content` mangles UTF-8](powershell/get-content-mangles-utf8.md) | ANSI default double-encodes diacritics; non-ASCII in the *pattern* makes `-replace` match nothing and exit 1. Use `System.IO.File` + BOM-less `UTF8Encoding`, check `git status` after a failed run |
| [Smart quotes are string delimiters](powershell/smart-quotes-are-string-delimiters.md) | `„`/`"` parse like `"`; localized text in single-quoted here-strings |
| [`'Stop'` + a native stderr warning = terminating error](powershell/erroractionpreference-stop-native-stderr.md) | `$ErrorActionPreference='Stop'` escalates a benign stderr warning (exit 0) to a script-killer; wrap in `'Continue'`, judge by `$LASTEXITCODE` |
| [`Get-PnPList -Identity` rejects server-relative URLs](powershell/get-pnplist-identity-rejects-server-relative-url.md) | Title/GUID/web-relative only; `/sites/team/shared` fails as "List does not exist" |
| [PS7 `[ref]` arguments break Office COM calls](powershell/ps7-ref-arguments-break-com-calls.md) | `SaveAs2([ref]$x)` dies on "psobject to Object"; pass values directly |
| [A syntax check under PS 7 proves nothing about 5.1](powershell/ps7-parse-check-misses-ps51-syntax-errors.md) | The parser belongs to the engine hosting it; ternary/`??`/`&&` pass in 7 and are parse errors in 5.1, and `#Requires` never warns you at authoring time |
| [`Export-Csv -Encoding UTF8` flips the BOM between 5.1 and 7](powershell/export-csv-utf8-bom-flips-between-versions.md) | Same parameter, different bytes; Excel reads encoding from the BOM, so the report is readable on one machine and mojibake on the next |
| [`$host` is a constant you cannot assign](powershell/host-is-a-constant-you-cannot-assign.md) | The obvious name for `[uri].Host` is reserved and the error blames the value; `$input`/`$args` accept the assignment silently and break later |
| [PS 5.1: `@(ConvertFrom-Json)` keeps the array wrapped](powershell/convertfrom-json-array-not-unwrapped-in-json-output.md) | `@()` around the pipeline doesn't enumerate: `foreach` over it runs ONCE with the whole array, and a later `+` nests the array so `ConvertTo-Json` writes `{value:[…],Count:n}` garbage; fix with `@((ConvertFrom-Json …))` |
| [`--query "length([?…])"` from PowerShell kills `az.cmd`](powershell/az-cmd-parentheses-in-query-break-cmd.md) | On Windows `az` is a batch file; a JMESPath query with `(` `)` and no spaces reaches cmd unquoted and breaks its IF block – return plain names and count in PowerShell |
| [File names in a generated `@" "@` here-string run as code](powershell/generated-script-data-injection.md) | Data that tenant users can name (file paths, titles) breaks every textual PowerShell literal – expandable here-strings run `$(…)`, `;` splits CSV rows, `'@` ends a literal here-string; embed data as base64 JSON instead |

### security/

| Gotcha | TL;DR |
|---|---|
| [Sanitizer keeps script and style TEXT](security/sanitizer-keeps-script-and-style-text.md) | An allowlist needs two sets: tags to keep, and tags to drop WITH their subtree; otherwise a pasted web page dumps its stylesheet into the article as visible text |
| [Stored XSS via list content](security/stored-xss-from-list-content.md) | React doesn't block `javascript:` hrefs; allowlist `safeHref` with C0-strip at every sink |
| [A formatter that returns raw input is a hole in your escaping](security/a-formatter-is-a-hole-in-your-escaping.md) | The unhappy branch returns the value verbatim; `about:blank` inherits your origin |
| [Field hiding is not a permission](security/field-hiding-is-not-a-permission.md) | Role-based UI field hiding is cosmetic; Read on the list = REST/Export/other web parts see it; confidentiality needs a separate list, item perms, or a server tier — plus the two traps the separate list creates: a sibling column that still holds the number, and "not allowed to read" arriving as a legitimate 0 in every aggregate and downloaded file |
| [Fewer sites and sudden 403s mean a different account](security/fewer-sites-and-403s-mean-a-different-account.md) | A tenant-wide snippet run from a second browser window executes as whoever is signed in there; print `currentuser` and the visible-site count before you blame the tenant |
| [Effective permissions come as a bitmask](security/effective-permissions-bitmask-off-by-one.md) | `ViewListItems` is bit 0, `Low`/`High` are decimal strings; decode off by one and every ordinary member looks locked out. Sanity-check the decoder against a site admin AND a known Read user |
| [CSV export executes formulas](security/csv-export-of-list-data-executes-formulas.md) | A member-written cell starting `= + - @` (or TAB/CR) runs in Excel on the reader's machine; quoting doesn't disarm it, an apostrophe prefix does |
| [URL sanitiser strips spaces](security/url-sanitiser-strips-spaces.md) | The C0 strip that blocks `java<TAB>script:` also eats the plain space, so every link to `/Shared Documents/…` 404s; strip to decide, return with `%20` |
| [An `image/*` upload accepts SVG](security/uploaded-svg-is-stored-xss.md) | SVG is a script: inert inside `<img>`, executed when the file's own URL is opened; allow-list raster MIME types |
| [Breaking inheritance copies foreign Edit grants](security/breaking-inheritance-copies-foreign-edit-grants.md) | Another app's groups arrive with `Edit`, which carries ManageLists and bypasses item-level security; break without copying and prune the ACL — but only on a genuinely polluted scope, because `copyRoleAssignments=false` keeps only the caller |
| [App-provisioned libraries inherit the web's write permissions](security/app-provisioned-library-inherits-web-write.md) | Any member can upload over REST and poison an AI grounded on that library; `WriteSecurity: 4` does not fix it (Edit includes Manage Lists and bypasses item-level settings) — only unique permissions on the library do |
| [A group created by code hides its own membership](security/group-created-by-code-hides-its-membership.md) | `sitegroups` POST defaults to `OnlyAllowMembersViewMembership: true`, so only a member or the account that ran provisioning can read the members; Full Control does not help and identical permission masks prove it |
| [An Entra group inside a SharePoint group is invisible to membership checks](security/entra-group-inside-a-sharepoint-group-is-invisible-to-membership-checks.md) | SharePoint accepts an Entra group as a member of a site group, then never expands it: `currentuser/groups` is direct-only and `sitegroups/{id}/users` returns the group, not its people — resolve with Graph `checkMemberGroups` and keep "couldn't check" apart from "not a member" |
| [Lock down a no-script site from code](security/locking-down-a-noscript-site-from-code.md) | On a modern site with custom scripts disabled, `NoCrawl` on the WEB returns 403 via REST and CSOM even for a site collection admin, and `SetUseAccessRequestDefaultAndUpdate(false)` returns 200 without effect; both work through the site settings pages, while `MembersCanShare` and the limited-access lockdown feature are writable via REST. Always re-read after writing a web setting. |

### tooling/

| Gotcha | TL;DR |
|---|---|
| [Driving an SPFx page from the console](tooling/driving-a-spfx-page-from-the-console.md) | `input.value` from the console leaves React state empty (use the native setter + input event) and `window.confirm` freezes CDP automation; both fail silently as success |
| [Measuring a page in a hidden tab](tooling/measuring-a-page-in-a-hidden-tab.md) | Hidden tabs never fire `requestAnimationFrame` and clamp `setTimeout` to ~1/s, so the run times out and looks like the very freeze you were chasing (and the code under test never ran either); CSS transitions never advance either, so `getBoundingClientRect()` keeps returning the previous position |
| [Diagnostics that cannot survive the crash](tooling/diagnostics-that-cannot-survive-the-crash.md) | Frozen threads stop repainting, `sessionStorage` dies with the tab and the next healthy run overwrites the crash report; persist synchronously and never gate recovery on a flag |
| [Getting bulk data into a SharePoint page from the console](tooling/getting-bulk-data-into-a-sharepoint-page-from-the-console.md) | Serve the payload from `127.0.0.1` with CORS instead of pasting it or uploading it; loopback is a trustworthy origin so HTTPS pages may fetch it – and when Chrome local network access blocks it ("Failed to fetch"), feed the files through a file input |
| [Git Bash mangles backslashes for native exes](tooling/git-bash-mangles-backslashes-for-native-exes.md) | `[\\/]` arrives as `[/]`; Windows-path regexes silently under-match |
| [Contributors panel keeps a co-author you removed](tooling/contributors-panel-stale-after-history-rewrite.md) | Panel and API answer different questions (co-authors vs authors), so verify at the source; know the documented ~24 h window before you start waiting |
| [GitHub Pages certificate stuck](tooling/github-pages-certificate-stuck.md) | Domain added before DNS existed; remove & re-add restarts provisioning |
| [NUL byte makes grep treat a file as binary](tooling/nul-byte-makes-grep-treat-file-as-binary.md) | One raw U+0000 and every grep-based sweep silently skips the file; write the escape, detect with `file` |
| [Compiled files next to sources fake your build check](tooling/compiled-files-next-to-sources-fake-your-build-check.md) | Stale `.js`/`.d.ts` in `src/` greps like live code while `main` points at `lib/`; ask `require.resolve`, date-check with `git log`, and keep the ignore rule narrow enough to spare hand-written `.d.ts` |
| [`git rm -r` leaves the directory, so tools report a phantom version](tooling/git-rm-leaves-the-directory-so-tools-report-a-phantom-version.md) | Untracked build leftovers keep a removed version folder alive on disk, so anything listing versions with `readdirSync` offers a build the site no longer serves; read published content from `git ls-tree` and fail closed when git cannot be read |
| [Word’s PDF export silently substitutes your fonts](tooling/word-pdf-export-substitutes-fonts.md) | `ExportAsFixedFormat` lays the text out in Calibri while Word still lists and embeds the font; print to PDF instead, install static (not variable) instances, and verify via the `name` table inside the embedded `FontFile2` |
| [A GitHub Pages CDN without `.nojekyll` freezes on its last good build](tooling/github-pages-without-nojekyll-freezes-a-static-cdn.md) | Branch builds run Jekyll unless `.nojekyll` exists; a growing bundle host ended in a bare "Page build failed." while the site kept serving the old build — read `/pages/builds` before pushing again |
| [Setting `process.env.TZ` inside Jest does not change the time zone](tooling/jest-process-env-tz-does-not-change-the-time-zone.md) | A test, `setupFiles` and `setupFilesAfterEnv` write into the sandbox's copy of `process.env`, so day-boundary tests pass only on machines already in the right zone; pin it in `globalSetup`, assert the offset, and run once under a foreign `TZ` (not from Git Bash on Windows) |
| [A retention window computed from git history deletes the file you overwrite in place](tooling/retention-by-git-history-deletes-files-overwritten-in-place.md) | A stable loader keeps its add date and drifts out of "the last N releases"; protect by shape (no content hash), and remember the hard 1 GB Pages limit counts the published tree |
| [A component from a linked package throws "Invalid hook call" in Jest](tooling/jest-loads-a-second-react-through-a-linked-package.md) | Jest resolves a `file:`/junction package by its real path, so the package's `require('react')` finds a second copy; map `react` and `react-dom` to the app's copy (the SPFx build is unaffected — React is a platform component) |
| [`TextEncoder is not defined` before your first test runs](tooling/textencoder-missing-in-jsdom-breaks-a-dependency-at-import.md) | jsdom has no Encoding API and a dependency that builds `new TextEncoder()` at module level dies on import, before `beforeAll`; import a polyfill module first or use `setupFiles`, and make the dependency lazy |
| [A bundle's `.LICENSE.txt` never reaches your CDN](tooling/bundle-license-files-never-reach-your-cdn.md) | webpack moves third-party license comments into `<bundle>.js.LICENSE.txt` and leaves only a pointer in the bundle; a publish step that copies `*.js`, or an allowlist `.gitignore`, ships the code without the texts MIT and BSD require. The file is build output: publish it next to its bundle, prune it with it, and make every build-vs-published check expect it |
| [`process.exit()` after `fetch()` lies about the exit code on Windows](tooling/process-exit-after-fetch-lies-about-the-exit-code.md) | On Windows (seen on Node.js 24), `process.exit()` right after the built-in `fetch()` sometimes aborts on a libuv assertion (`UV_HANDLE_CLOSING`) and ends with a crash code (3221226505, shown as 127 in Git Bash), so a check that has just printed OK reports a failure — intermittently. After the first network call, set `process.exitCode` and let the process finish; call `process.exit()` only before it |
| [A shell hook written without chmod runs on Windows and is skipped on Linux](tooling/git-hook-without-exec-bit-is-skipped-on-linux-ci.md) | Git ignores a hook without the executable bit, but Git for Windows does not check that bit and runs a hook that starts with `#!/bin/sh`, so a fixture that writes `.git/hooks/pre-commit` with `writeFileSync` blocks the commit locally and not on Linux CI; `chmod 0o755` after writing (harmless on Windows), and commit hook scripts with `git add --chmod=+x` |
| [`global.navigator = …` for jsdom is ignored on Node 21+](tooling/global-navigator-assignment-for-jsdom-is-ignored-on-node-21.md) | Node 21 added a global `navigator` that has a getter and no setter, so the classic hand-made jsdom setup line is silently ignored (strict mode throws a TypeError) and the test sees Node's `userAgent` (e.g. "Node.js/24") and no `onLine`; define the property with `Object.defineProperty` and assert it took effect |
| [Azure HD voice is generative — verify by transcription](tooling/azure-tts-hd-voice-is-generative-verify-by-transcription.md) | DragonHD output varies per call and sometimes garbles a term; transcribe each MP3 with fast transcription and regenerate on a miss. Use <lang> for a non-native language, playbackRate instead of the unsupported <prosody>, and honor Retry-After on 429 |
| [Root site swap refuses hub sites, and any site Publishing ever touched](tooling/site-swap-refuses-hub-sites-and-ever-published-sites.md) | `Invoke-SPOSiteSwap` rejects a source or target that is a hub site or associated with one, and rejects a source where the Publishing feature was *ever* activated — not just currently; unregistering a hub leaves children appearing associated for up to an hour, so remove the associations explicitly before the swap, and never activate Publishing on the site you intend to promote |
| [Jest's 5 s default timeout fails render tests under load — and the next test with them](tooling/jest-default-timeout-under-load-breaks-the-next-test.md) | The first jsdom + React test of a suite pays for the cold module load; on a busy build machine it passes 5 s, Jest abandons it while its async render keeps running, and the following test fails with "button not found" or a doubled write. Set `testTimeout` for the project, not per test |
| [Console fetch hangs under the ODSP service worker](tooling/console-fetch-hangs-under-odsp-service-worker.md) | A SharePoint page is controlled by the `odspserviceworkerproxy.aspx` service worker; when it gets stuck, every `fetch` from the page (even to another domain) never resolves while timers still run – a hard reload (Shift+F5) loads the page outside the worker and the script works |
| [ECDSA: Node DER vs WebCrypto r\|\|s](tooling/ecdsa-signature-node-der-vs-webcrypto-p1363.md) | crypto.sign in Node returns a DER-encoded ECDSA signature (69–72 bytes); WebCrypto verify in an SPFx bundle expects IEEE P1363 (64 bytes for P-256) and just returns false. Sign with dsaEncoding ieee-p1363, check the length, and test both directions with a DER counterexample |
| [Moving a static CDN off GitHub Pages without breaking the apps that load from it](tooling/moving-a-static-cdn-off-github-pages.md) | Upload git blobs, not a Windows working tree (CRLF breaks SRI); allow the new CA in CAA; missing files must stay 404, not 403; Azure static website has no CORS and Front Door ignores the query string by default; folders without a trailing slash; upload content before pointers |

## Writing your own

Use the skeleton in [CONTRIBUTING](../CONTRIBUTING.md) — one trap per file, error messages verbatim, code that fixes it.
