💥 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 |
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 |
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 | 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 |
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 | 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 | A_x0020_B comes back as A_x005f_x0020_x005f_B; decode _x005f_ before matching |
GetObjectSharingInformation traps |
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 |
The header behind mysterious search 500s |
| DateTime: write full ISO, derive days locally | 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 |
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 |
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 |
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 | encodeURIComponent leaves ' alone; double it |
An unencoded # in a $filter value cuts the URL |
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 |
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 |
The response already carries LoginName; a follow-up siteusers filter on Email misses accounts whose UPN differs |
| Choice fields accept any value | REST skips choice validation entirely; enforce vocabulary yourself |
| A decimal comma erases a Number column | 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 |
Read via $expand + projections, write via <Name>Id; ~12-lookup query limit |
File size needs $expand=File |
File_x0020_Size 400s in $select; use File/Length |
Uploaded .html gains mso document properties; Office files are rewritten on upload |
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 | Generic 500 naming no field; a Text column capped at 255 |
| Create a modern page via REST (3-step) | 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 |
Fall back to client filtering, but only on HTTP 400 |
GetStorageEntity returns 200 for a missing key |
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 | 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 | 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" | 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 |
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 | _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 | 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 |
$select is an allowlist; a snapshot ?? live fallback silently erases the feature the snapshot exists for |
| Silent fallbacks poison destructive writes | 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 | 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 | 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 AuthorIds 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 | 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 |
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 | 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 |
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 | _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 | 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 |
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 | Hidden is not enough; FromBaseType + CanBeDeleted are what separate the list's own columns |
| A page-size cap reported as a finding | 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 | /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 |
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 | 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 |
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 |
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 |
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 | 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 |
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 |
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 |
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 |
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 | 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) | 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 | fields/GetByInternalNameOrTitle on a missing field answers 400; test existence with a $filter instead |
| Attachment add changes the ETag | AttachmentFiles/add changes the item ETag; re-read before an IF-MATCH update |
ETag key is @odata.etag |
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 | 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 | 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 | 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 | 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 | 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 | 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 | { 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 |
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 |
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 | It's scanned rows, not returned rows; index early, page always |
View formatting JSON can't contain < or & |
It's stored inside the view's schema XML; reverse the comparison and nest if() |
An empty Date field is not '' |
Blank dates fall through to "overdue" and turn rows red; test .displayValue |
Gallery cards render from tileProps |
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 |
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 |
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 | 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 | 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 | 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 | 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 | 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 | 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 | 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) | 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 | 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 | 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 |
String length via indexOf(str + '^', '^'), or the expression collapses to empty |
MajorVersionLimit: 0 means unlimited, not none |
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 | 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 | 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 | 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 | 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 | @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 | 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 | 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 | 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 | 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 | 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 | 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 |
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 | 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 | "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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 |
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 | 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 |
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" | 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 | The lock defends itself against being lifted; infrastructure operations (version pin, re-check, config, diagnostics) belong outside it |
The ES2015 lib trap |
TS2550 on padStart & friends, and the safe equivalents |
| Dropping a folder does nothing | 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 | 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 |
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 | Reassigning client.api throws and your fail-safe catch hides it; use Object.create and assert it attached |
| SPA router hijacks anchor clicks | <a href> navigates before React onClick runs; use buttons for in-app actions |
stopPropagation in onClick does not stop the keyboard |
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 | url(images/...) without ./ kills the build; inject a <link> instead |
| Minified React errors cheatsheet | #310/#300/#321/#31/#185 decoded for SPFx debugging |
| People search endpoints that work | SP Search People source + ensureuser; why the obvious endpoints fail |
| Fixed dropdowns in transformed panels | The CSS transform containing-block trap; portal to document.body |
SP.WebProxy is add-in-only |
No SharePoint-native CORS proxy for SPFx; the 403 hides inside an HTTP 200 |
| Teams personal app needs global deploy | skipFeatureDeployment: true + "all sites"; the teams/ icon-folder convention |
| Teams mobile webview renders desktop width | ~980px layout; fix the viewport meta in Teams first |
| Shared component measures the viewport, not its container | @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 |
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 | 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 | 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 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 | An upload-size limit only bounds the compressed archive; a crafted !ref still OOMs the tab |
| A Promise over img.onload can hang forever | onload/onerror are not guaranteed to fire; try/catch guards rejection, not a Promise that never settles |
| Shared package's dynamic import ships inlined | 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 | The same page loads English once and Czech the next time; cache the resolved language per web instead |
| Centered flex clips on mobile | Centering + overflow cuts content above the scroll; flex "springs" |
| JSX attributes and smart quotes | Typographic quotes in attributes = TS1003; wrap as {'…'} |
| A conditional spread hides a phantom list column | ...(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 | 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 | An SPFx app writes through two clients; wrapping SPHttpClient leaves mail, calendar and Planner wide open |
| Portaled overlays miss your CSS reset | 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 |
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 | .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 |
Jump-to-section does nothing in a modal/reader; drop the flag and set scrollTop directly |
| Previewing library files in your own app | 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 | 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 | Measure-before-load (FOUT) sizes boxes for the fallback font; use a system font stack |
rules-of-hooks false-positive from a JSX && chain |
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 |
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 |
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 | 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 |
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 | 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 | 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 | 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 | 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 | 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 | "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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 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 | 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 | 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 |
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> |
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 | 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 | 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 | releasecommunications/api/v1/m365 answers 200 without Access-Control-Allow-Origin; proxy it server-side |
| pdf.js refuses two renders into one canvas | 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 | 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 | 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 | 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 | 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 |
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 | 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 | 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 | /\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 } | 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 | 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) | 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 | 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 | 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 | 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 |
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 |
Delegated Mail.Send can't impersonate; configure Reply-To instead |
A mailto: fallback reported as sent |
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 | 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 |
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 |
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 | "not a security principal"; scope app-only Mail.Send to a mail-enabled security group |
/me/todo needs an Exchange mailbox |
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 | /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 | Hour-long queries; attach to the last succeeded, create in background |
dont have any permissions rejects the user, not the app |
Consent vs. Exchange audit role; Global Admin usually inherits it, so compare accounts |
| A Teams channel exists immediately — its folder does not | 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 | 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 | 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 | getAllSites rejects delegated tokens; check the Permissions table first |
PATCH /me: directory vs profile fields |
Mixed field groups fail whole; two PATCHes, profile best-effort |
| az CLI can't grant Sites.Selected | 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 |
SPFx Graph traffic invisible in Network; diagnose via performance entries, currentuser, DOM, user's response |
| preAuthorizedApplications needs an existing scope | Graph checks delegatedPermissionIds against stored scopes; scope and its pre-authorization need two separate PATCH calls |
| Re-read fresh before bulk-removing members | 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 |
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 |
/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 |
.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 |
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 |
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 |
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 | 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 | 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 | 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) |
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 |
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 | messages?$top=100&$orderby=… returned 100 posts without @odata.nextLink; $top + $skip returned all 441 |
Group members/$ref needs object ID, not UPN |
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 |
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 | archivalDetails is unreadable once archived; POST /sites/{host},{siteCollectionId}/unarchive still works |
| isPrivileged is beta-only | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Corporate NAT makes per-IP per-company; metered probes silently hide the feature |
| A pinned Azure OpenAI model+version is a time bomb | "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 |
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 | 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 | 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 | 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 | 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 | 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 |
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 | 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 | 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-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 |
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 | 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 | 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 | 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 | 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 | 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 | 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 |
Windowed counts select but don't sort; re-rank client-side |
| Compare SharePoint paths decode-first | Normalize encoding + boundary-aware prefix, or matches never fire |
Don't trust the parsed-file-types table: SPO does index .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 |
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 |
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 | 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 |
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 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 | 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 |
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 | 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 |
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 |
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 |
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 | 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 |
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 | „/" parse like "; localized text in single-quoted here-strings |
'Stop' + a native stderr warning = terminating error |
$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 |
Title/GUID/web-relative only; /sites/team/shared fails as "List does not exist" |
PS7 [ref] arguments break Office COM calls |
SaveAs2([ref]$x) dies on "psobject to Object"; pass values directly |
| A syntax check under PS 7 proves nothing about 5.1 | 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 |
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 |
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 |
@() 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 |
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 |
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 | 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 | 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 | The unhappy branch returns the value verbatim; about:blank inherits your origin |
| Field hiding is not a permission | 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 | 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 | 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 | 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 | 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 |
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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | [\\/] arrives as [/]; Windows-path regexes silently under-match |
| Contributors panel keeps a co-author you removed | 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 | Domain added before DNS existed; remove & re-add restarts provisioning |
| NUL byte makes grep treat a file as binary | 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 | 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 |
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 | 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 |
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 |
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 | 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 | 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 |
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 |
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 |
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 | 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+ |
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 | DragonHD output varies per call and sometimes garbles a term; transcribe each MP3 with fast transcription and regenerate on a miss. Use |
| Root site swap refuses hub sites, and any site Publishing ever touched | 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 | 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 | 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 | 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 | 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 — one trap per file, error messages verbatim, code that fixes it.
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub