Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/workflows/npm-cd-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+"

permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
Expand All @@ -12,15 +16,16 @@ jobs:
run: corepack enable
- name: Set Yarn Version
run: corepack prepare yarn@4.7.0 --activate
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
cache: "yarn"
cache-dependency-path: yarn.lock
- name: Install modules
run: yarn install
- run: yarn test
- run: yarn build
- run: npm publish --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to npm with OIDC
run: NODE_AUTH_TOKEN="" npm publish --tag beta --provenance --access public
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@flagship.io/react-sdk",
"sideEffects": false,
"version": "5.2.3",
"version": "5.2.4",
"license": "Apache-2.0",
"description": "Flagship REACT SDK",
"main": "dist/src/index.js",
Expand Down
6 changes: 2 additions & 4 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
'use client';
import { useRef, useEffect } from "react";
import { useRef } from "react";
import { Visitor, primitive } from "./deps";


export function useLatestRef<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
ref.current = value;
return ref;
}

Expand Down
2 changes: 1 addition & 1 deletion src/sdkVersion.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Generated by genversion.
export const version = '5.2.3'
export const version = '5.2.4'
63 changes: 63 additions & 0 deletions test/StaleRefRepro.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react'

import { jest, expect, it, describe, beforeEach, afterEach } from '@jest/globals'
import { renderHook } from '@testing-library/react-hooks'
import { Mock } from 'jest-mock'

import { FSFlagCollection } from '@flagship.io/js-sdk'

import * as FsHooks from '../src/FlagshipHooks'

describe('repro: stale ref when reading getFlags() synchronously during render', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let realUseContext: <T>(context: React.Context<T>) => any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let useContextMock: Mock<(context: React.Context<any>) => any>

beforeEach(() => {
realUseContext = React.useContext
useContextMock = jest.fn()
React.useContext = useContextMock
})

afterEach(() => {
React.useContext = realUseContext
})

it('getFlags() called inline in render should reflect the visitor from the SAME render, not a previous one', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const readSnapshots: any[] = []

// Render 1: no visitor yet, still fetching
useContextMock.mockReturnValue({
state: { flagsStatus: { status: 'FETCHING', reason: 'NONE' }, flags: new Map() }
})

const { rerender } = renderHook(() => {
const hook = FsHooks.useFlagship()
// Mimic a consumer component that reads flags directly in its render body,
// e.g.: const { getFlags, flagsStatus } = useFlagship(); const flags = getFlags()
readSnapshots.push({ status: hook.flagsStatus, flags: hook.getFlags() })
return hook
})

// Render 2: visitor becomes ready AND flagsStatus flips to FETCHED in the SAME state update
const visitor = {
getFlags: jest.fn(() => new FSFlagCollection({ flags: new Map([['my-flag', { key: 'my-flag' }]]) as any }))
}
useContextMock.mockReturnValue({
state: { visitor, flagsStatus: { status: 'FETCHED', reason: 'NONE' } }
})

rerender()

const lastSnapshot = readSnapshots[readSnapshots.length - 1]

expect(lastSnapshot.status).toEqual({ status: 'FETCHED', reason: 'NONE' })
// This is the actual bug: with the old useEffect-based useLatestRef, the ref
// is still pointing at the *previous* (visitor-less) render when getFlags()
// is read synchronously during render, so this comes back empty even though
// flagsStatus already says FETCHED.
expect(lastSnapshot.flags.size).toBe(1)
})
})
Loading