<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sp0033212000</title>
    <description>The latest articles on DEV Community by sp0033212000 (@sp0033212000).</description>
    <link>https://dev.to/sp0033212000</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4193161%2F86d84b47-6ab1-413c-a920-fae2f7c789ff.jpg</url>
      <title>DEV Community: sp0033212000</title>
      <link>https://dev.to/sp0033212000</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sp0033212000"/>
    <language>en</language>
    <item>
      <title>Generating API Types When Frontend and Backend Both Live on Feature Branches</title>
      <dc:creator>sp0033212000</dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:08:03 +0000</pubDate>
      <link>https://dev.to/sp0033212000/generating-api-types-when-frontend-and-backend-both-live-on-feature-branches-288e</link>
      <guid>https://dev.to/sp0033212000/generating-api-types-when-frontend-and-backend-both-live-on-feature-branches-288e</guid>
      <description>&lt;p&gt;Our frontend API types are generated from the backend's OpenAPI (Swagger) spec with &lt;code&gt;swagger-typescript-api&lt;/code&gt;. With one project, that was a ten-line script: fetch the spec, write the file, done. Then we had more frontend projects, each with its own copy of that script, each a little different. Nobody could tell which copy had which fix.&lt;/p&gt;

&lt;p&gt;Branches were the real headache, though. When the backend builds a feature, it works on a feature branch, and the frontend needs types from &lt;em&gt;that&lt;/em&gt; branch, not from &lt;code&gt;dev&lt;/code&gt;. That raises two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How does the frontend know which branch's spec to fetch?&lt;/li&gt;
&lt;li&gt;What about the gap between the feature branch and &lt;code&gt;dev&lt;/code&gt;?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second one is sneakier. Once a backend feature branch is cut, it usually isn't rebased onto &lt;code&gt;dev&lt;/code&gt; very often. Meanwhile other features land in &lt;code&gt;dev&lt;/code&gt; along with their APIs, and none of those exist in your feature branch's spec. The frontend, on the other hand, rebases onto &lt;code&gt;dev&lt;/code&gt; all the time. The moment you do, you pull in code from other features that already calls those new APIs. Now your code uses endpoints that the spec you're generating types from has never heard of, and typecheck fails. You didn't break anything, but CI is red.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;backend  dev           ──o──────────●──────────────   ● other branch merges API X
backend  ft-coupon-v2     └──o──────────o──────────   no rebase: spec has no API X

frontend dev           ──o──────────────●──────────   ● code that calls API X
frontend ft-coupon-v2     └──o─────────────●───────   rebased onto dev: now calls API X
                                           │
                types generated from backend ft-coupon-v2 spec
                → API X missing → typecheck fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I built an internal CLI package that owns the whole "which spec, how to merge it, where to write it" job. Nine frontend projects use it today, and several teammates have added features and fixed bugs in it since. This post walks through how it works and the traps we hit merging two OpenAPI specs. That second part applies no matter what generator you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: same-named branches
&lt;/h2&gt;

&lt;p&gt;The answer to question one is a naming rule: &lt;strong&gt;the frontend branch uses the same name as the backend branch.&lt;/strong&gt; If the backend opens &lt;code&gt;ft-coupon-v2&lt;/code&gt;, so does the frontend.&lt;/p&gt;

&lt;p&gt;That means there's nothing to configure. The branch you're on already tells the tool which spec you want. Switch branches and your types follow. CI runs the same logic. It also makes conversations easier: when someone says &lt;code&gt;ft-coupon-v2&lt;/code&gt;, frontend and backend mean the same feature.&lt;/p&gt;

&lt;p&gt;A naming rule alone isn't enough, though. The frontend also needs to know whether the backend actually has a spec for that branch. So I asked the backend team for one more endpoint: a list of branches that currently have a deployed spec. Before generating, the tool calls it and looks for its own branch name:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Found:&lt;/strong&gt; fetch that branch's spec and merge it with &lt;code&gt;dev&lt;/code&gt;'s (more on the merge below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not found:&lt;/strong&gt; this branch only exists on the frontend side, so fall back to the staging spec.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current branch ──&amp;gt; branch-list endpoint ──&amp;gt; same name exists?
                                              │ yes ──&amp;gt; merge dev + branch spec ──┐
                                              │ no  ──&amp;gt; use staging spec ─────────┴──&amp;gt; generate types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody has to remember to point a config at the right branch anymore. Without this, it's easy to forget to switch it back and build production with feature-branch types, or to keep fetching a spec for a backend branch that was merged and deleted last week. Now the lookup decides.&lt;/p&gt;

&lt;p&gt;The same mapping applies at runtime. Every backend feature branch is deployed to its own endpoint, so by default the frontend calls the endpoint matching its branch, and the types and the backend you're talking to come from the same source. If you want a different backend (say you're on a feature branch but want to test against staging data), you set an environment variable. If it's set, it wins. If not, the tool silently uses the feature branch's endpoint. Day to day, you set nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basics: one config file, one command
&lt;/h2&gt;

&lt;p&gt;Each project has one config file at its root listing which docs to generate and where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;genConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;API_DOC_NAME&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@team/fe-manifest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;genConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/swagger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_DOC_NAME&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Public_API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputFileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public.api.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_DOC_NAME&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Member_API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputFileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;member.api.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;npx hc-manifest&lt;/code&gt; generates the typed clients. The &lt;code&gt;genConfig&lt;/code&gt; wrapper is there so the config file itself is type-checked: misspell a doc name and your editor tells you.&lt;/p&gt;

&lt;p&gt;To get the current branch name, the tool checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;--branch&lt;/code&gt; flag&lt;/li&gt;
&lt;li&gt;The current git branch&lt;/li&gt;
&lt;li&gt;CI environment variables: &lt;code&gt;GITHUB_HEAD_REF&lt;/code&gt;, &lt;code&gt;GITHUB_REF_NAME&lt;/code&gt;, EAS Build's &lt;code&gt;EAS_BUILD_GIT_COMMIT_BRANCH&lt;/code&gt;, and so on, first one with a value wins&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One gotcha: CI often checks out a commit SHA, and then &lt;code&gt;git rev-parse --abbrev-ref HEAD&lt;/code&gt; just returns &lt;code&gt;HEAD&lt;/code&gt;. Treat that as "no answer" and keep going to the environment variables, or you'll end up looking for a branch literally named &lt;code&gt;HEAD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once it has a name, it checks it against the backend's branch list and falls back to staging if there's no match. The generated file's header also records which branch it came from and when the spec was last updated. If neither has changed on the next run, generation is skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging two specs: don't just deep merge
&lt;/h2&gt;

&lt;p&gt;Back to question two. On a feature branch (ours start with &lt;code&gt;ft-&lt;/code&gt;), the tool fetches both the &lt;code&gt;dev&lt;/code&gt; spec and the feature branch spec, uses &lt;code&gt;dev&lt;/code&gt; as the base, and lays the feature branch on top. New or changed APIs from the feature branch win, and everything other people added to &lt;code&gt;dev&lt;/code&gt; stays.&lt;/p&gt;

&lt;p&gt;My first version used &lt;code&gt;lodash.merge&lt;/code&gt;. Seems reasonable, until you remember how it handles arrays: index by index. OpenAPI is full of arrays, and that produced two quiet bugs. Credit where it's due: teammates caught and fixed both of these in real projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: parameters merged into each other.&lt;/strong&gt; Say an operation in &lt;code&gt;dev&lt;/code&gt; has four parameters and the fourth is a header. The feature branch puts a new boolean query parameter in that same slot. Merge by index and those two unrelated parameters get blended into one. The boolean param came out typed as &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to merge parameters by identity (&lt;code&gt;name&lt;/code&gt; + &lt;code&gt;in&lt;/code&gt;), not position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mergeParamsByIdentity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;ftParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ftKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ftParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="c1"&gt;// feature branch params win; dev-only params are appended&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;ftParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;devParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ftKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)))];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2: an enum lost a value.&lt;/strong&gt; An enum in &lt;code&gt;dev&lt;/code&gt; had nine values. The feature branch was cut earlier and was missing one in the middle, so it had eight. Merged by index, everything after the gap shifted up by one and the last value showed up twice. The generated enum was quietly one value short, the frontend's typecheck broke, and nothing warned us. With a different ordering you can even get a combination that neither branch ever declared. &lt;code&gt;required&lt;/code&gt; arrays have the same problem.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;lodash.mergeWith&lt;/code&gt; with a customizer that decides how arrays merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mergeArrayCustomizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ftValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ftValue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// plain-value arrays like enum and required: take the union so nothing is lost&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isPureValueArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isPureValueArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ftValue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;devValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ftValue&lt;/span&gt;&lt;span class="p"&gt;])];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// arrays of objects or $refs: take the feature branch's array whole&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ftValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mergeWith&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;devSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ftSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mergeArrayCustomizer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// then re-merge each operation's parameters with mergeParamsByIdentity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The union has a side effect: if the feature branch removed an enum value on purpose, the union puts it back. The tool can't tell "not synced yet" from "deliberately removed", so I added a warning. Whenever the union keeps values the feature branch doesn't have, it prints the path and the values and lets a human decide. At least it's a visible trade-off now instead of a silent bug.&lt;/p&gt;

&lt;p&gt;One more edge case: if the feature branch explicitly says &lt;code&gt;parameters: []&lt;/code&gt; instead of omitting it, the "take the feature branch's object array whole" rule wipes out all of &lt;code&gt;dev&lt;/code&gt;'s parameters. So the parameter re-merge has to run when &lt;em&gt;either&lt;/em&gt; side has parameters, not only when both do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;operationIds with auto-added suffixes.&lt;/strong&gt; To keep &lt;code&gt;operationId&lt;/code&gt; unique, the backend's Swagger generator appends &lt;code&gt;_1&lt;/code&gt;, &lt;code&gt;_2&lt;/code&gt; to duplicates. But it checks across the whole spec, so two operations under different tags still get suffixed. &lt;code&gt;swagger-typescript-api&lt;/code&gt; generates one class per tag, so the frontend ended up calling &lt;code&gt;cancelSubmission1()&lt;/code&gt; instead of &lt;code&gt;cancelSubmission()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So before generating, the tool strips the suffix, but only when no other operation in the same tag already uses that name. Otherwise you'd get two methods with the same name in one class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sameTagConflict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operationId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sameTagConflict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;null&lt;/code&gt; vs &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; The backend returns &lt;code&gt;null&lt;/code&gt; for empty fields, so lots of generated types are &lt;code&gt;T | null&lt;/code&gt;. Most frontend code treats those fields as optional (&lt;code&gt;T | undefined&lt;/code&gt;), and the two never lined up. We handled it in two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; an axios response interceptor that strips &lt;code&gt;null&lt;/code&gt; or turns it into &lt;code&gt;undefined&lt;/code&gt;, and re-types each API's response to match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation:&lt;/strong&gt; a teammate added an option so the generated types are &lt;code&gt;T | undefined&lt;/code&gt; from the start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two have to go together. Types that say &lt;code&gt;undefined&lt;/code&gt; while runtime still hands you &lt;code&gt;null&lt;/code&gt; is worse than either alone. So the tool now also checks that the project has the interceptor wired up when it generates types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rewriting files when only the timestamp changed.&lt;/strong&gt; The generated file's header includes the spec's update time, so any redeploy of the spec rewrote the file even when nothing else changed, filling git diffs with noise. Now it compares content with the timestamp ignored and only writes when something real changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results and takeaways
&lt;/h2&gt;

&lt;p&gt;All nine frontend projects, Next.js sites and React Native apps, now generate types with the same command. Locally, it picks the right spec from your current branch. CI runs the same command, so if the backend API and the frontend's usage disagree, typecheck catches it before it ships.&lt;/p&gt;

&lt;p&gt;What I took away from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Let a naming convention replace configuration.&lt;/strong&gt; Same-named branches plus one branch-list endpoint turned "which spec do I need?" into something the tool works out, not something people have to remember.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't merge OpenAPI specs with a generic deep merge.&lt;/strong&gt; Index-based array merging is fine for most config objects but causes subtle bugs in schemas. Merge parameters by &lt;code&gt;name&lt;/code&gt; + &lt;code&gt;in&lt;/code&gt;, and union &lt;code&gt;enum&lt;/code&gt; and &lt;code&gt;required&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent errors are worse than loud ones.&lt;/strong&gt; A bad merge doesn't throw. It just produces a type that looks plausible. Wherever there's a trade-off (like the union bringing back a removed value), print a warning so people can see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write tests from real incidents.&lt;/strong&gt; For each merge bug, we turned the actual spec fragment that broke into a test and made sure it failed before the fix. That way nobody quietly undoes it later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared tools work best when everyone can change them.&lt;/strong&gt; I started this tool, but many of the important fixes came from teammates who hit problems in their own projects. The biggest win of pulling the scripts into one package: one person fixes it, nine projects get the fix.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openapi</category>
      <category>typescript</category>
      <category>frontend</category>
      <category>git</category>
    </item>
    <item>
      <title>LINE LIFF in a Next.js Frontend: The Pitfalls I Hit</title>
      <dc:creator>sp0033212000</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:45:45 +0000</pubDate>
      <link>https://dev.to/sp0033212000/line-liff-in-a-nextjs-frontend-the-pitfalls-i-hit-oo1</link>
      <guid>https://dev.to/sp0033212000/line-liff-in-a-nextjs-frontend-the-pitfalls-i-hit-oo1</guid>
      <description>&lt;p&gt;The booking site I work on is a Next.js app, and a lot of its users arrive by tapping a link in a shop's LINE Official Account. So we added LIFF early on, letting people sign in with LINE in one tap. Later we moved sign-in to an SSO SPA on a separate domain. That's when LINE's in-app browser and cross-domain redirects started fighting each other: pages stuck on "Loading...", sign-in loops, error popups nobody could make sense of. And many of them only happened on Android, or only on iPhone.&lt;/p&gt;

&lt;p&gt;This post collects the LINE pitfalls I ran into, each with the symptom, the cause and the fix. If your site gets opened inside LINE, hopefully it saves you some time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. LIFF login in an external browser loops forever on iPhone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; A user opens the site in Safari on iPhone and taps "Sign in". They get bounced to the LINE app, then back to Safari, and the page still shows the "Sign in" button. Tap it again, same loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; When you run the LIFF OAuth flow in an external browser, iOS hands the login over to the LINE app. When LINE hands control back to Safari, it opens a new tab instead of returning to the original one. Everything the OAuth flow needs, like the code verifier stored in the original tab, isn't visible from the new tab, so the flow just breaks.&lt;/p&gt;

&lt;p&gt;I tried a few workarounds, including forcing the external-browser OAuth flow and building the OAuth URL by hand with a parameter to stop the app from taking over. None of them worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Only use LIFF login when the page is actually running inside the LINE app. External browsers get a different sign-in method (SMS in our case). For the check I didn't write my own User-Agent rules. LINE publishes &lt;code&gt;@liff/is-in-client&lt;/code&gt;, which runs the same logic as &lt;code&gt;liff.isInClient()&lt;/code&gt; and doesn't need &lt;code&gt;liff.init()&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isInClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@liff/is-in-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isLiffInAppBrowser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// It reads navigator and sessionStorage synchronously, so guard SSR&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isInClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One related gotcha: make the LIFF login &lt;code&gt;redirectUri&lt;/code&gt; point back to the page the user was actually on. In one case we were sending them to the home page, so after signing in with LINE they lost the store page they'd been looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;liff.init()&lt;/code&gt; can reload the current URL
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After signing in, the user lands back on our callback page, and the request that exchanges the one-time code for a token goes out twice. The second one fails with a 400.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; We ran &lt;code&gt;liff.init()&lt;/code&gt; on every page load. Inside LINE's in-app browser, &lt;code&gt;liff.init()&lt;/code&gt; can navigate to the current URL again. On the callback page, that means reloading with the same single-use code, so the code gets redeemed twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; You need both of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't run &lt;code&gt;liff.init()&lt;/code&gt; on the callback page.&lt;/strong&gt; All that page does is trade a code for a token. It doesn't need LIFF at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redeem each code exactly once.&lt;/strong&gt; If a remount calls it again, join the request that's already in flight. If the page reloads, reuse the result you recorded instead of calling the API again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second point isn't really LINE-specific. Any page that handles a single-use code (OAuth, magic links and so on) should assume it might get loaded twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Changing &lt;code&gt;location&lt;/code&gt; before &lt;code&gt;liff.init()&lt;/code&gt; finishes, and Android never navigates
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Before redirecting to the sign-in page, we show a "Loading..." overlay. In some Android LINE builds that overlay just stays there forever, and the URL never changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; LINE's own docs warn about this: if you touch &lt;code&gt;location&lt;/code&gt; in the same tab while &lt;code&gt;liff.init()&lt;/code&gt; is still running, some Android LIFF WebViews never actually navigate. Our redirect logic and &lt;code&gt;liff.init()&lt;/code&gt; lived in different components, so they raced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Wait for &lt;code&gt;liff.init()&lt;/code&gt; before redirecting. There's a catch, though: &lt;code&gt;liff.init()&lt;/code&gt; has no timeout of its own. If it hangs (say the SDK never finishes loading), waiting on it forever just moves the "stuck on Loading" bug somewhere else. So I wait at most 5 seconds, then redirect anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LIFF_INIT_WAIT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// In LIFF, let liff.init() settle before touching location&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLiffMode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;liffReady&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;waitElapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setWaitElapsed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;LIFF_INIT_WAIT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;startRedirect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLiffMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;liffReady&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitElapsed&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Android LIFF can swallow cross-domain redirects
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Even after waiting for &lt;code&gt;liff.init()&lt;/code&gt;, some Android devices still got stuck. Users who cancelled on the sign-in page also came back to a blank callback page that went nowhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Some Android LIFF browsers silently swallow a same-tab cross-domain &lt;code&gt;location.assign()&lt;/code&gt; or &lt;code&gt;location.replace()&lt;/code&gt;. No error, the tab stays alive, it just doesn't go anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I couldn't fix this at the root, so the code checks whether the redirect took, and tries again if it didn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REDIRECT_RETRY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redirectWithRetry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onStuck&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Still on this page after 2 seconds? Try again&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// If that didn't work either, let the caller show a manual retry button&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onStuck&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onStuck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REDIRECT_RETRY_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;REDIRECT_RETRY_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the redirect does go through, the page is gone and the timers never fire, so there's no need for an extra check. &lt;code&gt;onStuck&lt;/code&gt; is the last line of defense: if there's a loading overlay on screen, the user at least gets a button to retry, instead of staring at it forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. LIFF APIs only work after &lt;code&gt;init()&lt;/code&gt; finishes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After a booking, we use &lt;code&gt;liff.sendMessages()&lt;/code&gt; to post a booking summary into the user's chat. Sentry kept reporting errors from that spot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; The code used &lt;code&gt;liff.isLoggedIn()&lt;/code&gt; to decide whether it could send. But the &lt;code&gt;liff&lt;/code&gt; object exists as soon as you import it. That doesn't mean it has been initialized. Before &lt;code&gt;init()&lt;/code&gt; finishes, or if it never runs at all (say the user opened the site in an external browser), those APIs aren't usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't use the presence of the &lt;code&gt;liff&lt;/code&gt; object as your check. Track your own "init finished" flag and only call other LIFF APIs once it's true.&lt;/li&gt;
&lt;li&gt;For nice-to-have features like &lt;code&gt;sendMessages()&lt;/code&gt;, just log the failure. Don't let it interrupt the booking flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What debugging taught me
&lt;/h2&gt;

&lt;p&gt;With all of these, the time went into finding the cause, not writing the fix. A few lessons worth writing down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't &lt;code&gt;alert()&lt;/code&gt; raw errors at users.&lt;/strong&gt; For a while, users inside LINE kept seeing popups like "TypeError: Load failed" and "Network Error". My first theory was a LINE redirect timing issue, so I tried delaying redirects by a few seconds. That did nothing. Testing on a real device eventually showed the page was navigating away while &lt;code&gt;liff.init()&lt;/code&gt; still had requests in flight. The interrupted request threw, and there happened to be an &lt;code&gt;alert(e)&lt;/code&gt; in that code path. But a failed &lt;code&gt;liff.init()&lt;/code&gt; already falls back to SMS sign-in, so the popup told users nothing useful. Removing it fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check your own monitoring config.&lt;/strong&gt; The "stuck on Loading" bug produced zero errors in Sentry. It turned out &lt;code&gt;ignoreErrors&lt;/code&gt; was dropping &lt;code&gt;Network Error&lt;/code&gt;, &lt;code&gt;Load failed&lt;/code&gt; and friends in every environment, staging included. The errors we'd filtered out to cut noise were exactly the ones I was hunting. Now they're only ignored in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Some errors aren't yours.&lt;/strong&gt; Sentry also had a steady stream of TypeErrors about &lt;code&gt;window.webkit.messageHandlers&lt;/code&gt; being undefined. They came from a third-party script (most likely a tag in GTM) running inside Instagram's and Facebook's in-app browsers. It assumed it was inside a native app's WebView and tried to call a native bridge that doesn't exist on &lt;code&gt;pagehide&lt;/code&gt;. By then the user has already left, it isn't our code, and there's nothing to fix, so it gets filtered in Sentry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No DevTools on the device? Put the console on the page.&lt;/strong&gt; Load vConsole or eruda outside production and you can read logs right inside LINE. In LINE's in-app browser, that's close to essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;If your site gets opened inside LINE, run through these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only use LIFF login inside the LINE app; give external browsers another way in, and check with &lt;code&gt;@liff/is-in-client&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Point the LIFF login &lt;code&gt;redirectUri&lt;/code&gt; back at the page the user was on&lt;/li&gt;
&lt;li&gt;Don't run &lt;code&gt;liff.init()&lt;/code&gt; on a callback page that redeems a one-time code, and redeem each code exactly once&lt;/li&gt;
&lt;li&gt;Wait for &lt;code&gt;liff.init()&lt;/code&gt; before changing &lt;code&gt;location&lt;/code&gt;, but cap the wait&lt;/li&gt;
&lt;li&gt;Check that cross-domain redirects actually happened, retry if not, and give users a manual retry as a last resort&lt;/li&gt;
&lt;li&gt;Only call other LIFF APIs after init, and don't let nice-to-have features break the main flow&lt;/li&gt;
&lt;li&gt;Don't &lt;code&gt;alert()&lt;/code&gt; raw errors, and make sure your monitoring isn't ignoring the errors you're looking for&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LINE's in-app browser behaves differently from a normal browser in a lot of ways, and Android and iPhone differ from each other on top of that. My biggest takeaway: anything people will use inside LINE needs to be tested inside the real LINE app, on both kinds of phone.&lt;/p&gt;

&lt;p&gt;References:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/liff/opening-liff-app/" rel="noopener noreferrer"&gt;Opening a LIFF app (LINE Developers)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/liff/differences-between-liff-browser-and-line-in-app-browser/" rel="noopener noreferrer"&gt;Differences between the LIFF browser and LINE's in-app browser (LINE Developers)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>line</category>
      <category>liff</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Passkeys in In-App Browsers: The API Is There, but It Won't Work</title>
      <dc:creator>sp0033212000</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:20:14 +0000</pubDate>
      <link>https://dev.to/sp0033212000/passkeys-in-in-app-browsers-the-api-is-there-but-it-wont-work-3m9a</link>
      <guid>https://dev.to/sp0033212000/passkeys-in-in-app-browsers-the-api-is-there-but-it-wont-work-3m9a</guid>
      <description>&lt;p&gt;I recently added passkeys to a member login page, and LINE's in-app browser gave me one of the sneakiest bugs I've dealt with in a while. The passkey button showed up. Tapping it did nothing. No system prompt, no error, nothing in the console.&lt;/p&gt;

&lt;p&gt;This post walks through what was going on, plus three things I think anyone shipping passkeys should know: how to tell whether passkeys will actually work, why &lt;code&gt;NotAllowedError&lt;/code&gt; doesn't mean the user cancelled, and why a &lt;code&gt;null&lt;/code&gt; from your backend can make Safari throw before the ceremony even starts. All the code is ready to copy. If your site gets opened from inside LINE, Facebook, Instagram or similar apps, this probably applies to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;While testing on staging, I opened the login page inside LINE. The passkey button was there, but tapping it did nothing at all.&lt;/p&gt;

&lt;p&gt;With no error to look at, I started with a screen recording and stepped through it frame by frame. The "verifying" state showed up for exactly one frame at 30fps. So &lt;code&gt;navigator.credentials.get()&lt;/code&gt; was being called, and it was failing within a few dozen milliseconds. Why was the screen blank? Because our code treated every &lt;code&gt;NotAllowedError&lt;/code&gt; as "the user cancelled", and a cancel shouldn't show an error. The failure was being swallowed.&lt;/p&gt;

&lt;p&gt;Next I added logging on a real device. On iOS 26.6 with LINE 26.12.1, I got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supported()  = true          // the shallow check passes
IUVPAA       = false (12ms)  // the platform honestly says no
get()        -&amp;gt; NotAllowedError after 4ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody cancels anything in 4ms. The system prompt hadn't even been drawn yet. LINE's WebView exposes the WebAuthn API, but it doesn't actually let you use it. That's decided by the host app and the OS, and nothing on the page can change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Checking that the API exists isn't enough
&lt;/h2&gt;

&lt;p&gt;A lot of tutorials decide whether to offer passkeys like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PublicKeyCredential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// show the passkey button&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine in a regular browser. In an in-app browser it isn't. The WebView usually runs on the system's browser engine, so the API is there, but whether a ceremony can actually run is up to the app wrapping it.&lt;/p&gt;

&lt;p&gt;The more reliable approach is to ask the platform whether it can do this right now. There are two APIs for that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()&lt;/code&gt; asks whether the device has a platform authenticator that can verify the user (Face ID, a fingerprint reader, Windows Hello). It's widely supported.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PublicKeyCredential.getClientCapabilities()&lt;/code&gt; is newer, from WebAuthn Level 3, and returns a whole set of capabilities at once. It needs Chrome or Edge 133, Firefox 135, or Safari 17.4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inside LINE, the first one honestly returns &lt;code&gt;false&lt;/code&gt;. Use it to decide whether to show the passkey option, and the dead button goes away. Here's the version I ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PROBE_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Cached, so the page only asks once&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canUsePasskey&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;capability&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PKC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PublicKeyCredential&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PKC&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Newer API: prefer it when it's there&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;PKC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getClientCapabilities&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PKC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClientCapabilities&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;PROBE_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userVerifyingPlatformAuthenticator&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userVerifyingPlatformAuthenticator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Older API: much wider support&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;PKC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isUserVerifyingPlatformAuthenticatorAvailable&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;PKC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isUserVerifyingPlatformAuthenticatorAvailable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="nx"&gt;PROBE_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Some restricted WebViews return a promise that never settles&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on why it looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The timeout isn't optional.&lt;/strong&gt; Some restricted WebViews hand back a promise that never resolves or rejects. Without a timeout, your login page just hangs on the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache it.&lt;/strong&gt; Login, enrollment and passkey management all need the answer, so ask once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getClientCapabilities()&lt;/code&gt; can return &lt;code&gt;undefined&lt;/code&gt; for a key&lt;/strong&gt;, which means the browser doesn't know. In that case it falls back to the older API. In my own project I only use the older API, and I've confirmed it returns &lt;code&gt;false&lt;/code&gt; inside LINE. I haven't verified what the newer one returns across different apps' WebViews, so test it on real devices before relying on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why not just sniff the User-Agent for LINE? Because the platform already gives you an honest answer, and UA rules rot as apps ship new versions. If LINE ever enables passkeys, a UA check would keep those users locked out. The trade-off is that a desktop with only a USB security key and no Touch ID or Windows Hello gets filtered out too. For a phone-first product, I'm fine with that.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;NotAllowedError&lt;/code&gt; doesn't mean the user cancelled
&lt;/h2&gt;

&lt;p&gt;The reason the failure was invisible in the first place: the code mapped every &lt;code&gt;NotAllowedError&lt;/code&gt; to "user cancelled". That's a really common pattern, but &lt;code&gt;NotAllowedError&lt;/code&gt; covers a lot more than that. The user closing the prompt, the request timing out, no usable credential, and, as here, the environment refusing outright can all come back as the same error.&lt;/p&gt;

&lt;p&gt;That's on purpose. If every failure had its own error, a site could use them to figure out whether a given credential exists on the device, and identify the user without their consent. So the browser won't tell you the exact reason, and you have to infer it.&lt;/p&gt;

&lt;p&gt;The most useful signal turns out to be &lt;strong&gt;time&lt;/strong&gt;. To cancel, a person has to see the system prompt and tap cancel. That can't happen in a few milliseconds. So I time the call, and anything rejected too quickly counts as "passkeys don't work here", and the user gets pointed to another way to sign in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Nobody can see a system prompt and dismiss it in under 300ms&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HUMAN_CANCEL_FLOOR_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PasskeyFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPasskeyAssertion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CredentialRequestOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[passkey] rejected after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;DOMException&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NotAllowedError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PasskeyFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;HUMAN_CANCEL_FLOOR_MS&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PasskeyFailure&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;unavailable&lt;/code&gt;, show something like "Passkeys aren't supported here, try signing in with SMS". Only on &lt;code&gt;cancelled&lt;/code&gt; should you stay quiet.&lt;/p&gt;

&lt;p&gt;Where does 300ms come from? The rejection I measured in LINE took 4ms, so 300ms leaves about 75 times that margin and still sits well below how long a real person takes to cancel. It's a heuristic, though, not something the spec guarantees. It also only makes sense for the modal flow. If you use conditional mediation (passkeys in the autofill dropdown), there's no prompt to time, so don't apply it there.&lt;/p&gt;

&lt;p&gt;In theory the check from section 1 already filters these environments out. This is the backstop for any WebView that lies about its capabilities, so you never ship another button that does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A &lt;code&gt;null&lt;/code&gt; from the backend, a TypeError from Safari
&lt;/h2&gt;

&lt;p&gt;Around the same time I hit a second passkey bug. During registration, Safari and Chrome on iOS 18 threw a TypeError before the system prompt ever appeared.&lt;/p&gt;

&lt;p&gt;The backend was serializing unset WebAuthn fields as explicit &lt;code&gt;null&lt;/code&gt; instead of leaving them out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authenticatorSelection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"residentKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userVerification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preferred"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JavaScript, &lt;code&gt;null&lt;/code&gt; and "not there" feel interchangeable. The browser doesn't see it that way. It converts your options into WebAuthn's types using WebIDL rules, and some of those fields don't accept &lt;code&gt;null&lt;/code&gt;. An explicit &lt;code&gt;null&lt;/code&gt; fails the conversion and throws, while a missing field just gets its default.&lt;/p&gt;

&lt;p&gt;The fix is simple. Strip the &lt;code&gt;null&lt;/code&gt;s before handing the options to the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stripNulls&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stripNulls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;stripNulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stripNulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creationOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real fix is for the backend to stop sending &lt;code&gt;null&lt;/code&gt;s. But a small guard on the frontend means you don't have to worry about the next empty field someone adds.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you really need passkeys inside LINE
&lt;/h2&gt;

&lt;p&gt;Once you've detected that passkeys won't work, falling back to SMS or email isn't the only option. You can also send the user to their default browser. LINE supports a URL parameter, &lt;code&gt;openExternalBrowser=1&lt;/code&gt;, that makes a link tapped inside LINE open in the external browser instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/login?openExternalBrowser=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't apply to LIFF apps, and it takes the user out of LINE, so whether it's worth it depends on your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging inside in-app browsers
&lt;/h2&gt;

&lt;p&gt;The hard part this time wasn't the fix. It was not being able to see the error. You can't attach DevTools to an in-app browser, so here's what helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn on &lt;a href="https://github.com/liriliri/eruda" rel="noopener noreferrer"&gt;eruda&lt;/a&gt; outside production.&lt;/strong&gt; It drops a small console onto the page so you can read logs on the phone itself. Load it after the page's &lt;code&gt;load&lt;/code&gt; event so it doesn't interfere with what you're trying to observe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the environment first.&lt;/strong&gt; On page load, dump the UA, the origin, whether &lt;code&gt;PublicKeyCredential&lt;/code&gt; and friends exist, and what the capability check returned. That one set of numbers is what cracked this bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count frames in a screen recording.&lt;/strong&gt; Before you have any logging, step through a recording frame by frame to see how long a state actually lasts. A one-frame "verifying" state is what told me the call was failing, rather than the button not being wired up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the decision before you act on it.&lt;/strong&gt; For the 300ms threshold, I first had the code just log what the threshold would have decided. Only after the numbers looked right on real devices did I let it change behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;If your site gets opened inside in-app browsers, here's what to check when you ship passkeys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Use &lt;code&gt;getClientCapabilities()&lt;/code&gt; or &lt;code&gt;isUserVerifyingPlatformAuthenticatorAvailable()&lt;/code&gt; to decide whether to show passkeys, not just whether the API exists&lt;/li&gt;
&lt;li&gt;[ ] Put a timeout on the check and cache the result&lt;/li&gt;
&lt;li&gt;[ ] Don't treat every &lt;code&gt;NotAllowedError&lt;/code&gt; as a cancel; give users a way out when it's rejected too fast&lt;/li&gt;
&lt;li&gt;[ ] Strip &lt;code&gt;null&lt;/code&gt; fields before calling &lt;code&gt;navigator.credentials&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Test inside the actual apps on real devices; desktop emulation won't catch any of this&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;References:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/webauthn-client-capabilities" rel="noopener noreferrer"&gt;Simpler WebAuthn feature detection with getClientCapabilities() (web.dev)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lists.w3.org/Archives/Public/public-webauthn/2024Sep/0001.html" rel="noopener noreferrer"&gt;Review privacy concerns around error conditions (W3C public-webauthn)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.classmethod.jp/articles/line-open-uri-in-external-browser/" rel="noopener noreferrer"&gt;Opening LINE URLs in the external browser (Classmethod, Japanese)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webauthn</category>
      <category>passkeys</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Team of One: Fixing a Broken Test Suite Between Feature Requests</title>
      <dc:creator>sp0033212000</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:02:39 +0000</pubDate>
      <link>https://dev.to/sp0033212000/turning-a-broken-test-suite-into-a-deploy-gate-59no</link>
      <guid>https://dev.to/sp0033212000/turning-a-broken-test-suite-into-a-deploy-gate-59no</guid>
      <description>&lt;p&gt;In September I ran &lt;code&gt;yarn test&lt;/code&gt; on our NestJS API and 71 of 87 suites failed.&lt;/p&gt;

&lt;p&gt;Honestly, I wasn't surprised. I'm the only engineer at the company, so the website, the back office, the API and every deploy are on me. When a feature request comes in, the feature gets built first and the tests wait. And since CI never ran the tests, nobody knew they were broken.&lt;/p&gt;

&lt;p&gt;But being the only engineer also means nobody reviews my code. Tests are the only safety net I've got. So I started fixing them in the gaps between feature work, a bit at a time. By the end of the month the suite was green, it ran in 95 seconds instead of 335, and a failing test blocked the deploy. This post is about how I decided what to fix first, and what not to fix at all, when there wasn't much time to go around.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it got that bad
&lt;/h2&gt;

&lt;p&gt;The API runs on Cloud Run and deploys through Cloud Build, which had two steps: build the image, deploy it. Tests never ran, so they quietly rotted.&lt;/p&gt;

&lt;p&gt;Almost every failure looked like this, before a single assertion ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nest can't resolve dependencies of the RentalService
(PrismaService, GoogleService, FormatHelperService, GoogleMapService, ?, UserService).
Please make sure that the argument SystemService at index [4] is available...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that hurt was the rental service spec. It had 19 tests covering real business logic, and they used to pass. Then &lt;code&gt;SystemService&lt;/code&gt; was added to the constructor and all 19 broke. With 70 other suites already red, nobody noticed. Me included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short on time, so save what matters
&lt;/h2&gt;

&lt;p&gt;I didn't have time to fix 71 suites one by one, so the first step was triage: which of these were actually worth my time?&lt;/p&gt;

&lt;p&gt;69 of them were Nest CLI scaffolding. &lt;code&gt;nest g service&lt;/code&gt; generates a spec with a single &lt;code&gt;it('should be defined')&lt;/code&gt; and provides the service without any of its dependencies, so any service with a constructor argument fails from day one. These had never passed.&lt;/p&gt;

&lt;p&gt;That raised the obvious question: should I even keep them? My test was simple. If I delete this file, do we lose anything we know about how the code behaves? A &lt;code&gt;should be defined&lt;/code&gt; stub only checks that Nest can wire the class, and the app already proves that every time it starts. Keeping them would mean a mock list per service, updated on every constructor change. For a team of one that cost is way too high, and the fact that it hadn't happened 69 times says it all.&lt;/p&gt;

&lt;p&gt;So I fixed the 21 real tests first. The rental spec just needed mocks for its new dependencies. The listing controller spec was failing because &lt;code&gt;AuthGuard&lt;/code&gt; needed a &lt;code&gt;UserService&lt;/code&gt;. Instead of providing it, I overrode the guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTestingModule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ListingController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ListingService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;useValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;listingServiceMock&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;overrideGuard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guard behavior belongs in the auth module's own tests. Making every controller spec deal with the guard's dependencies is exactly what broke this one. With the override in place, future changes to the guard won't break controller tests, and I won't have to come back and fix them.&lt;/p&gt;

&lt;p&gt;I kept the stubs for five core modules: listing, rental, users, webhook and auth. They handle data and permissions, so a broken constructor there hurts the most, and that's a maintenance cost I'm willing to pay. The other 62 went. I thought about &lt;code&gt;describe.skip&lt;/code&gt;, but that just turns the screen green. The problem stays, and I'd have to deal with it all over again later.&lt;/p&gt;

&lt;p&gt;Last, I ran the suite with the database URL and LINE credentials blanked out. Still green, so every test was properly mocked and CI wouldn't need any secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a failing test block the deploy
&lt;/h2&gt;

&lt;p&gt;Fixing the tests was only half the job. Nobody reviews my code, so without something forcing the issue, tests would start breaking again the next time I'm rushing a feature. Once the suite was green, I added a test step at the start of Cloud Build. Cloud Build stops at the first failing step, so a failing test means no image and no deploy.&lt;/p&gt;

&lt;p&gt;Its first three runs on staging all failed, each for a different reason, and none of them reproduced on my laptop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Prisma client wasn't generated in the CI container, so every model type was missing. Fix: run &lt;code&gt;prisma generate&lt;/code&gt; before the tests.&lt;/li&gt;
&lt;li&gt;Jest ran out of memory while type-checking the generated client. Fix: raise Node's heap size and recycle workers with &lt;code&gt;--workerIdleMemoryLimit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One module created an SDK client from credentials at import time and crashed before any test ran. Fix: dummy values for those variables in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Moving the tests to GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Running tests in Cloud Build worked, but every build reinstalled dependencies the Dockerfile had already cached, and failures were buried in the Cloud Build logs. Digging through those every time adds up when you're the only one doing it. So I moved the tests to GitHub Actions. The workflow only starts Cloud Build after the tests pass, and a failure shows up as a red X right on the commit.&lt;/p&gt;

&lt;p&gt;The catch is that the build no longer protects itself. It's up to the workflow to make sure only tested commits get deployed. The relevant parts look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# checkout, setup-node, yarn install, prisma generate&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yarn test --ci --shard=${{ matrix.shard }}/3&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# authenticate with Workload Identity Federation&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcloud builds triggers run "$TRIGGER" --sha="${{ github.sha }}" ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--sha&lt;/code&gt; builds the commit that passed. Without it, &lt;code&gt;triggers run&lt;/code&gt; builds whatever the branch points to at that moment, so if I push again while the tests are running, the newer, untested commit gets deployed.&lt;/li&gt;
&lt;li&gt;The per-branch &lt;code&gt;concurrency&lt;/code&gt; group, with the deploy job waiting for the build to finish, means an older build can never land after a newer one and overwrite it.&lt;/li&gt;
&lt;li&gt;Workload Identity Federation means there's no service account key stored in GitHub, which is one less secret to manage or worry about leaking. Only this repo's &lt;code&gt;staging&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; branches can get credentials.&lt;/li&gt;
&lt;li&gt;GitHub's runner was noticeably slower than Cloud Build, and a single test job would have made deploys slower than before, so the suite runs as three parallel shards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also used &lt;code&gt;triggers run&lt;/code&gt; rather than &lt;code&gt;gcloud builds submit&lt;/code&gt;. &lt;code&gt;submit&lt;/code&gt; uploads the working directory as the build source, and after a test run that directory is full of &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking that it works
&lt;/h2&gt;

&lt;p&gt;I pushed a test I'd broken on purpose. One shard failed, the deploy job was skipped, and Cloud Build never saw that commit. That's when I actually trusted it.&lt;/p&gt;

&lt;p&gt;The first timed run caught a mistake of mine too. Push to deploy took over 18 minutes, and it turned out I'd forgotten to remove the old test step from Cloud Build, so every commit was being tested twice. After removing it, it came down to about 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it ended up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Failing test suites&lt;/td&gt;
&lt;td&gt;71 of 87&lt;/td&gt;
&lt;td&gt;0 of 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test run&lt;/td&gt;
&lt;td&gt;335 s&lt;/td&gt;
&lt;td&gt;95 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests block deploys&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where failures show&lt;/td&gt;
&lt;td&gt;Nowhere&lt;/td&gt;
&lt;td&gt;A red X on the commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push to production&lt;/td&gt;
&lt;td&gt;~10 min, untested&lt;/td&gt;
&lt;td&gt;~12 min, tested&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deploys take about as long as before, but every one is tested now, and the whole thing runs without me babysitting it. When you're the only engineer, that matters a lot more than saving a few minutes. If I were starting over, I'd add the gate on day one. Everything in this post is me catching up on what I didn't do back then.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
